Skip to content

pierresisson/papyrus

Repository files navigation

papyrus

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.

The 3-second workflow

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.

Why?

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.

Installation

git clone https://github.com/pierresisson/papyrus.git
cd papyrus
pnpm install
pnpm build
pnpm link --global

papyrus is now available everywhere in your terminal.

Quick Start

# 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-formats

Shell Aliases (recommended)

Add 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: pslack uses htmlcopy, a small Swift utility that copies HTML as rich text to the macOS clipboard. See htmlcopy setup below.

Supported Formats

Format ID Output Description
Slack slack Text mrkdwn — Slack's markup syntax
Email 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

Format Mapping

Markdown Slack Email 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

CLI Reference

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

Examples

# 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/

GFM Support

papyrus supports GitHub Flavored Markdown out of the box:

  • Tables
  • Task lists (- [x] / - [ ])
  • Strikethrough (~~text~~)
  • Autolinks

How It Works

                         ┌─────────────┐
                         │  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.

Development

# 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 slack

Project Structure

src/
  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

Adding a New Format

  1. 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 ''
  },
}
  1. Register it in src/index.ts
  2. Add 'myformat' to the FormatId union in src/types.ts
  3. Add tests in tests/formats/myformat.test.ts

Built With

Roadmap

  • 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)

Contributing

Contributions are welcome. Please open an issue first to discuss what you'd like to change.

  1. Fork the repo
  2. Create your branch (git checkout -b feat/my-format)
  3. Write tests for your changes
  4. Ensure all tests pass (pnpm test)
  5. Commit and open a pull request

htmlcopy Setup

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/htmlcopy

Make sure ~/.local/bin is in your PATH.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors