git clone https://github.com/substackdown/substackdown.git
cd substackdown
npm install
npm run build
npm testFor the VS Code extension:
cd vscode-extension
npm install
npm run buildsrc/
parser.ts .sub text -> AST (hand-rolled line-by-line parser)
emitter.ts AST -> ProseMirror JSON (Substack's draft_body format)
decompiler.ts ProseMirror JSON -> AST -> .sub text
html-emitter.ts AST -> Substack-styled HTML (for preview)
cli.ts CLI entry point, all commands
sync.ts Substack API client (auth, push, pull, publish)
manifest.ts .substackdown/manifest.json CRUD and state tracking
validate.ts Frontmatter validation
types.ts AST type definitions (BlockNode, InlineNode, Mark)
index.ts Public API exports
parser.test.ts 108 tests — parsing, round-trips, integration
manifest.test.ts 33 tests — CRUD, hashing, state machine
sync.test.ts 39 tests — auth, API mocking, retries, errors
cli.test.ts 43 tests — all CLI commands via subprocess
validate.test.ts 17 tests — frontmatter validation
export.test.ts 27 tests — markdown export conversion
vscode-extension/
src/extension.ts VS Code extension entry point
src/webview.ts TipTap-based preview panel
syntaxes/ TextMate grammar for .sub files
npm run build # TypeScript -> JavaScript
npm test # Run all tests via Node's built-in test runner
npm run test:src # Run tests directly from TypeScript (uses tsx)Tests use Node's built-in test runner (node:test + node:assert/strict). No external test framework needed.
.sub text -> parse() -> SubDocument { frontmatter, body: BlockNode }
|
emit(body) -> ProseMirror JSON string
|
decompile(json) -> SubDocument
|
render(doc) -> .sub text
The round-trip must be idempotent. If you change the parser, verify with:
substackdown roundtrip test/bugwoman.subIn src/types.ts, add a new variant to the BlockNode union:
| { type: 'my_widget'; someAttr: string; sourceLine?: number }In src/parser.ts, update the @-block parsing section. For self-closing blocks, add to the attribute extraction switch. For content blocks, add to the content block handler.
In src/emitter.ts, add a case to emitBlock() that produces the ProseMirror JSON node Substack expects. Check Substack's editor source for the exact PM node type and attributes.
In src/decompiler.ts, add a case to decompileBlock() that converts the PM JSON node back to your AST type. Also add to renderBlock() to produce the .sub text.
Add parsing tests in src/parser.test.ts and a round-trip test. The round-trip test is the most important — it ensures parse -> emit -> decompile -> render reproduces the input.
In src/html-emitter.ts, add rendering for the preview.
- TypeScript with strict mode
- ESM modules (
.jsextensions in imports) - No external runtime dependencies (the CLI is zero-dependency)
- Tests use
describe/itfromnode:test - Temp directories for filesystem tests,
mock.fn()for fetch mocking - Commit messages: imperative mood, explain the "why"
The core pipeline files (parser.ts, emitter.ts, decompiler.ts, types.ts) should only be modified when adding new block types or fixing parser bugs. Changes to these files require round-trip verification against test/draft-body.json.