Convert Markdown to multiple output formats — Slack, Email, Google Docs, Notion, and plain text.
papyrus parses Markdown into an AST (mdast) and transforms it into platform-specific formats through a plugin architecture. Each format handles the quirks and constraints of its target platform, so your content always looks native.
Cmd+C → pslack → Cmd+V
Copy your Markdown, type pslack in your terminal, paste into Slack. Bold, bullets, links — everything is formatted natively. Works with pmail, ptxt, pnotion, pgdocs too.
Markdown is the native format of the GenAI era. Every document, skill, prompt, spec, and README is written in it. But each platform speaks its own language — Slack uses *bold* instead of **bold**, email needs inline CSS, Google Docs expects batch API requests, and Notion has its own block format. papyrus bridges that gap: write once in Markdown, output anywhere.
git clone https://github.com/pierresisson/papyrus.git
cd papyrus
pnpm install
pnpm build
pnpm link --globalpapyrus is now available everywhere in your terminal.
# Convert to Slack mrkdwn
papyrus README.md -f slack
# Convert to email-safe HTML
papyrus README.md -f email -o email.html
# Pipe from stdin
echo "**hello** _world_" | papyrus -f slack
# → *hello* _world_
# Output Notion blocks as JSON
papyrus README.md -f notion > blocks.json
# List available formats
papyrus --list-formatsAdd these to your ~/.zshrc for the fastest workflow:
# Pipe a file → clipboard
alias toslack="papyrus -f email | htmlcopy && echo '✓ slack'"
alias tomail="papyrus -f email | pbcopy && echo '✓ email html'"
alias totxt="papyrus -f plaintext | pbcopy && echo '✓ plaintext'"
# Clipboard → convert → clipboard (the magic ones)
alias pslack="pbpaste | papyrus -f email | htmlcopy && echo '✓ slack'"
alias pmail="pbpaste | papyrus -f email | pbcopy && echo '✓ email html'"
alias ptxt="pbpaste | papyrus -f plaintext | pbcopy && echo '✓ plaintext'"
alias pnotion="pbpaste | papyrus -f notion | pbcopy && echo '✓ notion'"
alias pgdocs="pbpaste | papyrus -f gdocs | pbcopy && echo '✓ gdocs'"Then:
Cmd+C → pslack → Cmd+V into Slack
Cmd+C → pmail → Cmd+V into your email client
Cmd+C → ptxt → Cmd+V anywhere as plain text
Note:
pslackuseshtmlcopy, a small Swift utility that copies HTML as rich text to the macOS clipboard. See htmlcopy setup below.
| Format | ID | Output | Description |
|---|---|---|---|
| Slack | slack |
Text | mrkdwn — Slack's markup syntax |
email |
HTML | Responsive HTML with all CSS inlined — works across email clients | |
| Google Docs | gdocs |
JSON | batchUpdate requests ready to POST |
| Notion | notion |
JSON | Block objects ready for the Notion API |
| Plain Text | plaintext |
Text | Clean plain text with structural formatting preserved |
| Markdown | Slack | Plain Text | |
|---|---|---|---|
**bold** |
*bold* |
<strong> |
bold |
*italic* |
_italic_ |
<em> |
italic |
~~strike~~ |
~strike~ |
<del> |
strike |
`code` |
`code` |
<code> with inline styles |
code |
[text](url) |
<url|text> |
<a href="url"> |
text (url) |
# Heading |
*Heading* |
<h1> with inline styles |
HEADING |
| Tables | Code block | <table> with inline styles |
ASCII aligned |
- [x] task |
:white_check_mark: |
checkbox | [x] task |
papyrus [file] [options]
Arguments:
file Markdown file to convert (reads stdin if omitted)
Options:
-f, --format <format> Output format: slack, email, gdocs, notion, plaintext (default: "slack")
-o, --output <file> Output file (stdout if omitted)
--base-url <url> Base URL for resolving relative image paths
--list-formats List available output formats
-V, --version Output the version number
-h, --help Display help
# Slack: paste directly into a message
papyrus notes.md -f slack | pbcopy
# Email: generate an HTML file to send
papyrus newsletter.md -f email -o newsletter.html
# Google Docs: pipe into your script that calls the Docs API
papyrus spec.md -f gdocs | node update-doc.js
# Notion: create blocks via the Notion API
papyrus page.md -f notion | curl -X PATCH \
'https://api.notion.com/v1/blocks/{block_id}/children' \
-H 'Authorization: Bearer secret_...' \
-H 'Content-Type: application/json' \
-H 'Notion-Version: 2022-06-28' \
-d @-
# Plain text: strip all formatting
papyrus README.md -f plaintext
# Resolve relative image URLs for email
papyrus post.md -f email --base-url https://cdn.example.com/images/papyrus supports GitHub Flavored Markdown out of the box:
- Tables
- Task lists (
- [x]/- [ ]) - Strikethrough (
~~text~~) - Autolinks
┌─────────────┐
│ Markdown │
│ input │
└──────┬──────┘
│
┌──────▼──────┐
│ Parser │
│ (remark) │
└──────┬──────┘
│
┌─────▼─────┐
│ mdast │
│ (AST) │
└─────┬─────┘
│
┌─────────────────┼─────────────────┐
│ │ │ │ │
┌─────▼────┐┌─────▼────┐┌─────▼────┐┌─────▼────┐┌─────▼─────┐
│ Slack ││ Email ││ GDocs ││ Notion ││ PlainText │
│ plugin ││ plugin ││ plugin ││ plugin ││ plugin │
└─────┬────┘└─────┬────┘└─────┬────┘└─────┬────┘└─────┬─────┘
│ │ │ │ │
mrkdwn HTML JSON JSON text
Each format plugin implements the same interface:
interface FormatPlugin<T = string> {
id: FormatId
name: string
extension: string
convert(tree: Root, options?: ConvertOptions): T
}The parser (unified + remark-parse + remark-gfm) converts Markdown to an mdast AST. Each plugin walks the AST and produces output in the target format. String formats (Slack, Email, Plain Text) return string. Structured formats (Google Docs, Notion) return typed objects that the CLI serializes as JSON.
# Clone and install
git clone https://github.com/pierresisson/papyrus.git
cd papyrus
pnpm install
# Build
pnpm build
# Run tests
pnpm test
# Watch mode (rebuild on change)
pnpm dev
# Test the CLI locally
echo "**hello**" | node dist/cli.js -f slacksrc/
cli.ts CLI entry point (commander)
index.ts Public API — convert(), listFormats()
parser.ts Markdown → mdast (unified + remark)
types.ts FormatPlugin interface, type definitions
formats/
slack.ts mdast → Slack mrkdwn
email.ts mdast → email-safe HTML (inline CSS)
gdocs.ts mdast → Google Docs API requests
notion.ts mdast → Notion block objects
plaintext.ts mdast → clean plain text
tests/
parser.test.ts
formats/ One test file per format plugin
fixtures/ Markdown test files
- Create
src/formats/myformat.ts:
import type { Root } from 'mdast'
import type { FormatPlugin } from '../types.js'
export const myFormatPlugin: FormatPlugin = {
id: 'myformat' as any,
name: 'My Format',
extension: '.txt',
convert(tree: Root): string {
// Walk the AST and produce output
return ''
},
}- Register it in
src/index.ts - Add
'myformat'to theFormatIdunion insrc/types.ts - Add tests in
tests/formats/myformat.test.ts
- unified — content processing pipeline
- remark-parse — Markdown parser
- remark-gfm — GitHub Flavored Markdown support
- mdast — Markdown Abstract Syntax Tree
- commander — CLI framework
- tsup — TypeScript bundler
- vitest — Test framework
- Custom plugin loading (
--plugin ./my-format.js) - Configuration file (
.papyrusrc.json) - Watch mode (
--watch) - Format auto-detection from output file extension
- WASM build for browser usage
- Bidirectional conversion (target format → Markdown)
Contributions are welcome. Please open an issue first to discuss what you'd like to change.
- Fork the repo
- Create your branch (
git checkout -b feat/my-format) - Write tests for your changes
- Ensure all tests pass (
pnpm test) - Commit and open a pull request
htmlcopy is a small macOS utility that copies HTML to the clipboard as rich text (RTF). This is what makes pslack work — Slack's paste handler interprets rich text correctly, unlike plain mrkdwn.
# Compile and install
cat > /tmp/htmlcopy.swift << 'SWIFT'
import Cocoa
let data = FileHandle.standardInput.readDataToEndOfFile()
guard let html = String(data: data, encoding: .utf8) else { exit(1) }
var processed = html
processed = processed.replacingOccurrences(of: "<ul[^>]*>", with: "", options: .regularExpression)
processed = processed.replacingOccurrences(of: "</ul>", with: "")
processed = processed.replacingOccurrences(of: "<ol[^>]*>", with: "", options: .regularExpression)
processed = processed.replacingOccurrences(of: "</ol>", with: "")
processed = processed.replacingOccurrences(of: "<li[^>]*>", with: "<p>• ", options: .regularExpression)
processed = processed.replacingOccurrences(of: "</li>", with: "</p>")
guard let htmlData = processed.data(using: .utf8) else { exit(1) }
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
guard let attrStr = try? NSAttributedString(data: htmlData, options: options, documentAttributes: nil) else { exit(1) }
let rtfData = try! attrStr.data(from: NSRange(location: 0, length: attrStr.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf])
let pb = NSPasteboard.general
pb.clearContents()
pb.setData(rtfData, forType: .rtf)
pb.setString(attrStr.string, forType: .string)
SWIFT
swiftc /tmp/htmlcopy.swift -o ~/.local/bin/htmlcopyMake sure ~/.local/bin is in your PATH.