Skip to content

Latest commit

 

History

History
347 lines (240 loc) · 8.47 KB

File metadata and controls

347 lines (240 loc) · 8.47 KB

substackdown

Write Substack posts in plain text. Preview in WYSIWYG. Publish from the terminal.

SubstackDown is a markdown-like DSL and CLI for writing, managing, and publishing Substack newsletters from your editor. It round-trips perfectly between .sub files and Substack's internal format, so you never lose formatting.

Installation

CLI (npm)

npm install -g substackdown

Requires Node.js 18 or later.

VS Code Extension

Search for SubstackDown in the VS Code Marketplace, or:

code --install-extension substackdown.substackdown

The extension provides syntax highlighting, a bidirectional WYSIWYG preview, and scroll sync for .sub files. Open any .sub file and press Cmd+Shift+V (Mac) or Ctrl+Shift+V (Windows/Linux) to open the preview.

Quick Start

Go from zero to published post in 5 steps:

1. Authenticate

Get your session cookie from Substack (Chrome > DevTools > Application > Cookies > copy substack.sid), then:

substackdown auth add https://yourpub.substack.com YOUR_SID_COOKIE

2. Write a post

Create a file called my-post.sub:

---
title: My First Post
subtitle: Written in plain text
audience: everyone
---

# Hello from SubstackDown

This is my first post written in **plain text** and published from the terminal.

Here's a [link](https://example.com) and some `inline code`.

@callout
SubstackDown supports all Substack features: paywalls, embeds, pullquotes, and more.
@end

@youtube(videoId=dQw4w9WgXcQ)

3. Preview locally

substackdown preview my-post.sub -o preview.html
open preview.html

4. Push to Substack as a draft

substackdown push my-post.sub
# Pushed! Post ID: 189339371
# Tip: Add "post_id: 189339371" to your frontmatter for future pushes.

5. Publish

substackdown publish my-post.sub --send

That's it. Your post is live and emailed to subscribers.

The .sub Format

SubstackDown is a superset of Markdown. Standard Markdown works as expected, plus @-block extensions for Substack-specific features.

Frontmatter

---
title: My Post Title
subtitle: A subtitle
post_id: 189339371
audience: everyone
section: essays
send_email: true
schedule: 2026-03-01T14:00:00Z
---
Key Values Description
title string Post title (required for publish)
subtitle string Post subtitle
post_id number Substack post ID (auto-set after first push)
audience everyone, only_paid, founding, paid Who can read the post
section string Publication section name
send_email true, false Send email on publish
schedule ISO 8601 datetime Schedule publication

Standard Markdown

# Heading 1
## Heading 2 through ###### Heading 6

**bold** *italic* ***bold italic*** `code` ~~strikethrough~~
~subscript~ ^superscript^

[link text](https://example.com)

> Blockquote

- Bullet list
  - Nested item

1. Ordered list
2. Second item

---

Hard line break: <br>

Code blocks:

```python
def hello():
    print("world")
```

Escape Characters

Use \ to escape special characters: \*, \`, \~, \[, \@, \\, \^.

@-Blocks (Substack Extensions)

Self-closing blocks:

@paywall
@button(url=https://example.com, text=Click Me)
@share
@youtube(videoId=dQw4w9WgXcQ, startTime=30)
@tweet(url=https://twitter.com/user/status/123)
@bluesky(url=https://bsky.app/profile/user/post/123)
@spotify(url=https://open.spotify.com/track/123)
@vimeo(videoId=12345)
@embed-post(url=https://pub.substack.com/p/post-slug)
@embed-pub(url=https://pub.substack.com)

Content blocks (closed with @end):

@pullquote(align=center, color=#ff6719)
A memorable quote that stands out.
@end

@callout
Important information here.
@end

@subscribe
Subscribe to get full access
@end

@image(src=https://example.com/photo.jpg, alt=Description, width=800)
Caption text with *inline* formatting
@end

@gallery
![Alt text 1](https://img1.jpg)
![Alt text 2](https://img2.jpg)
@end

@footnote(1)
Footnote content goes here.
@end

@latex
E = mc^2
@end

Inline extensions:

Footnote reference: ^[1]
Inline LaTeX: @latex(x^2 + y^2 = z^2)

Command Reference

Local Commands

substackdown compile <file.sub>         Convert .sub to ProseMirror JSON
substackdown decompile <file.json>      Convert ProseMirror JSON to .sub
substackdown roundtrip <file.sub>       Compile then decompile (test fidelity)
substackdown preview <file.sub>         Render Substack-styled HTML preview

Auth

substackdown auth add <url> <sid>       Add a publication
substackdown auth list                  List configured publications
substackdown auth use <name>            Set default publication

Supports multiple publications. Switch between them with auth use.

Cloud Sync

substackdown pull <post-id> [-o file]   Pull draft from Substack
substackdown push <file.sub>            Push .sub to Substack draft
substackdown list [--json]              List recent drafts
substackdown publish <file.sub>         Publish (--send / --no-send)
substackdown schedule <file.sub>        Schedule (--at <ISO-datetime>)
substackdown unschedule <file.sub>      Unschedule a draft

Batch Operations

substackdown batch push <glob>          Push multiple .sub files
substackdown batch publish <glob>       Publish multiple drafts

Project Management

substackdown init                       Initialize .substackdown/ tracking
substackdown status [file.sub] [--json] Show article status
substackdown diff <file.sub>            Diff local vs remote content
substackdown logs                       Show operation log

Run substackdown init in your project root to enable deployment tracking. The manifest tracks each article's lifecycle: new -> draft -> scheduled -> published -> changed.

Diagnostics

substackdown doctor                     Check auth, connectivity, config
substackdown export <post-id>           Export post as standard Markdown

Global Options

Flag Description
-o <file> Write output to file instead of stdout
--verbose Log HTTP request/response details to stderr
--json Output machine-readable JSON (list, status)
--help Show help text

VS Code Extension

The extension provides:

  • Syntax highlighting for .sub files with TextMate grammar
  • Bidirectional WYSIWYG preview powered by TipTap/ProseMirror, styled to match Substack
  • Live sync: edit the source or the preview and changes flow both ways
  • Scroll sync between source and preview panels

Building from Source

cd vscode-extension
npm install
npm run build

Architecture

.sub source -> parse() -> AST -> emit() -> ProseMirror JSON -> Substack API
                                                |
ProseMirror JSON -> decompile() -> AST -> render() -> .sub source

The pipeline has three stages:

  1. Parser (src/parser.ts): Hand-rolled line-by-line parser producing an AST of block and inline nodes.
  2. Emitter (src/emitter.ts): Converts AST to ProseMirror JSON, the format Substack's API expects.
  3. Decompiler (src/decompiler.ts): Reverse pipeline, turning ProseMirror JSON back into .sub source.

The round-trip is idempotent: parse -> emit -> decompile -> render produces the original source.

Troubleshooting

"No auth configured"

Run substackdown auth add <url> <sid> with your Substack publication URL and session cookie. To find your SID:

  1. Open your Substack in Chrome
  2. DevTools (F12) > Application > Cookies > substack.com
  3. Copy the value of substack.sid

"API error 401"

Your session cookie has expired. Get a fresh one from Chrome DevTools and run auth add again.

"You appear to be offline"

SubstackDown checks DNS before making API calls. Verify your internet connection and try again.

Round-trip produces different output

Run substackdown roundtrip <file.sub> to see exactly which lines differ. Common causes: trailing whitespace, inconsistent list markers, or unsupported Substack editor features.

"No body content found in draft"

The post exists but has no content. It may be a placeholder created through the Substack web UI.

VS Code preview not updating

Close and reopen the preview panel (Cmd+Shift+V). If the issue persists, reload the VS Code window.

Run diagnostics

substackdown doctor

This checks your auth configuration, tests the API connection, and reports any issues.

License

MIT