Repository files navigation

OpenBird

Publish Markdown as shareable web pages with a single command.

An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.


Part 1: User Guide

Features

  • One command publishes Markdown to beautiful, permanent web pages
  • Zero-config temporary publishing (--temp, no login, auto-expires in 1h)
  • Local images auto-upload to cloud storage
  • username/slug namespace for permanent URLs
  • Completely free for personal/small team use (Cloudflare Free Tier)
  • Zero npm dependencies

Quick Start

Prerequisites

  • Node.js 18+

1. Install the CLI

git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0

2. Login

openbird login

Your browser will open a login page. Enter your credentials to get an API key, which is saved automatically.

No account? Use the demo account below, or deploy your own backend (see Part 2).

3. Publish Your First Document

echo"# Hello OpenBird"> hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lake

Open the URL in your browser to see the rendered page.

Don't want to log in? Use --temp for a 1-hour temporary page:

echo"# Quick Test"> /tmp/test.md
openbird publish --temp /tmp/test.md
# → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed

Demo Account

ItemValue
Usernamedemo
Passworddemo@123
Backend URLhttps://openbird.jhao.space

Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.

Command Reference

openbird login

Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.

openbird login

You can also provide the API key via environment variable (for CI/CD):

export OPENBIRD_API_KEY="ob_xxx"

openbird publish

Publish or update a Markdown document.

# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt
FlagDescription
--slug <value>Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens)
--namespacePublish to username/<slug> permanent URL, slug auto-allocated or specified with --slug
--tempTemporary publish, no login required, auto-expires in 1 hour

Output:

✨ Published → https://openbird.jhao.space/my-custom-url

Updating an existing document:

✓ Updated → https://openbird.jhao.space/my-custom-url

openbird list

List all documents published by the current user.

openbird list

Output:

 my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents

Namespace documents are shown as username/slug.

openbird remove

Delete a published document.

# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-page

Output:

✓ Removed my-custom-url

Configuration

Environment Variables

VariableDescriptionDefault
OPENBIRD_API_URLWorker API URLhttps://openbird.jhao.space (public instance)
OPENBIRD_API_KEYAPI Key (takes precedence over credentials file)None

Credentials File

The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.

# Manual setup
mkdir -p ~/.config/openbird
echo"ob_your_api_key">~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentials

Mapping File (.openbird)

When you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:

# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page

Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.


Part 2: Self-Hosting

Prerequisites

npm install -g wrangler
wrangler login

1. Deploy the Backend

Option 1: One-Click Script (Recommended)

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.sh

The script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.

Option 2: Manual Deployment

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above# [[kv_namespaces]]# binding = "USERS"# id = "your-id"# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.dev
Optional: Bind a Custom Domain

After deployment, you can bind a custom domain:

  1. Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
  2. Add a custom domain (e.g. openbird.yourdomain.com)

2. Point the CLI to Your Instance

export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"

3. Admin Account

Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.

Admins can create additional users with openbird register:

openbird register --email user@example.com --password "password" [--username custom-name]

Architecture

Overview

CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response

A single Cloudflare Worker handles everything: API, page serving, and image proxy.

Data Storage

StoragePurposeFree Tier
KV USERSUser accounts, API key hashes, email index1 GB
KV DOCSDocument metadata (slug, title, expiry)1 GB
R2 PAGESRendered HTML pages10 GB
R2 IMAGESUser-uploaded images10 GB

Markdown Rendering

The Worker has a built-in zero-dependency Markdown renderer supporting:

  • Headings (h1-h6)
  • Bold, italic, inline code
  • Links, images
  • Unordered and ordered lists
  • Blockquotes, horizontal rules
  • Tables
  • Fenced code blocks

Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.


API Documentation

All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).

POST /api/v1/register

Admin only. Creates a new user account. Requires the admin's API key.

curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'
FieldTypeRequiredDescription
emailstringyesUser email address
passwordstringnoAuto-generated random password if omitted
usernamestringnoCustom username, defaults to email local-part if omitted

Response (201):

{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}

Non-admin callers receive:

{
"error": "Registration is closed"
}

POST /api/v1/publish

Publish or update a document.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
slugstringnoCustom URL slug, auto-generated if omitted
namespacedbooleannoSet to true to publish to username/slug (requires username)
titlestringnoPage title, extracted from first # Title if omitted

Response (201 created / 200 updated):

{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}

POST /api/v1/publish (Guest)

No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
tempbooleanyesMust be true, otherwise returns 401
slugstringnoCustom slug, auto-generated if omitted
titlestringnoPage title

Response (201):

{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}

GET /api/v1/documents

List all documents for the current user.

curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"

Response (200):

{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}

Results are sorted by updatedAt descending. Namespace documents have a non-null username field.

DELETE /api/v1/documents

Delete a document.

# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"

Response (200):

{ "ok": true }

POST /api/v1/upload-image

Upload an image.

curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"

Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.

Response (200):

{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}

GET /:slug

View a published page.

curl https://openbird.jhao.space/my-page
# → HTML document

GET /:username/:slug

View a namespaced page.

curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document

Development

Local Development

# Start local Worker (with KV + R2 simulation)cd worker
wrangler dev
# → Ready on http://localhost:8787# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'

Point the CLI to your local Worker:

cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.md

Project Structure

pagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json

Tech Stack

LayerTechnology
CLINode.js 18+ ESM, zero dependencies
WorkerCloudflare Workers (V8 isolate)
Page StorageCloudflare R2
Index StorageCloudflare KV
Image StorageCloudflare R2
Deploymentwrangler CLI

License

MIT

About

Open-source, self-hosted Markdown publishing. One CLI command turns Markdown into a shareable web page — free forever on Cloudflare.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

OpenBird

Publish Markdown as shareable web pages with a single command.

An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.


Part 1: User Guide

Features

  • One command publishes Markdown to beautiful, permanent web pages
  • Zero-config temporary publishing (--temp, no login, auto-expires in 1h)
  • Local images auto-upload to cloud storage
  • username/slug namespace for permanent URLs
  • Completely free for personal/small team use (Cloudflare Free Tier)
  • Zero npm dependencies

Quick Start

Prerequisites

  • Node.js 18+

1. Install the CLI

git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0

2. Login

openbird login

Your browser will open a login page. Enter your credentials to get an API key, which is saved automatically.

No account? Use the demo account below, or deploy your own backend (see Part 2).

3. Publish Your First Document

echo"# Hello OpenBird"> hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lake

Open the URL in your browser to see the rendered page.

Don't want to log in? Use --temp for a 1-hour temporary page:

echo"# Quick Test"> /tmp/test.md
openbird publish --temp /tmp/test.md
# → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed

Demo Account

ItemValue
Usernamedemo
Passworddemo@123
Backend URLhttps://openbird.jhao.space

Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.

Command Reference

openbird login

Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.

openbird login

You can also provide the API key via environment variable (for CI/CD):

export OPENBIRD_API_KEY="ob_xxx"

openbird publish

Publish or update a Markdown document.

# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt
FlagDescription
--slug <value>Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens)
--namespacePublish to username/<slug> permanent URL, slug auto-allocated or specified with --slug
--tempTemporary publish, no login required, auto-expires in 1 hour

Output:

✨ Published → https://openbird.jhao.space/my-custom-url

Updating an existing document:

✓ Updated → https://openbird.jhao.space/my-custom-url

openbird list

List all documents published by the current user.

openbird list

Output:

 my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents

Namespace documents are shown as username/slug.

openbird remove

Delete a published document.

# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-page

Output:

✓ Removed my-custom-url

Configuration

Environment Variables

VariableDescriptionDefault
OPENBIRD_API_URLWorker API URLhttps://openbird.jhao.space (public instance)
OPENBIRD_API_KEYAPI Key (takes precedence over credentials file)None

Credentials File

The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.

# Manual setup
mkdir -p ~/.config/openbird
echo"ob_your_api_key">~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentials

Mapping File (.openbird)

When you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:

# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page

Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.


Part 2: Self-Hosting

Prerequisites

npm install -g wrangler
wrangler login

1. Deploy the Backend

Option 1: One-Click Script (Recommended)

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.sh

The script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.

Option 2: Manual Deployment

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above# [[kv_namespaces]]# binding = "USERS"# id = "your-id"# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.dev
Optional: Bind a Custom Domain

After deployment, you can bind a custom domain:

  1. Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
  2. Add a custom domain (e.g. openbird.yourdomain.com)

2. Point the CLI to Your Instance

export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"

3. Admin Account

Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.

Admins can create additional users with openbird register:

openbird register --email user@example.com --password "password" [--username custom-name]

Architecture

Overview

CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response

A single Cloudflare Worker handles everything: API, page serving, and image proxy.

Data Storage

StoragePurposeFree Tier
KV USERSUser accounts, API key hashes, email index1 GB
KV DOCSDocument metadata (slug, title, expiry)1 GB
R2 PAGESRendered HTML pages10 GB
R2 IMAGESUser-uploaded images10 GB

Markdown Rendering

The Worker has a built-in zero-dependency Markdown renderer supporting:

  • Headings (h1-h6)
  • Bold, italic, inline code
  • Links, images
  • Unordered and ordered lists
  • Blockquotes, horizontal rules
  • Tables
  • Fenced code blocks

Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.


API Documentation

All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).

POST /api/v1/register

Admin only. Creates a new user account. Requires the admin's API key.

curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'
FieldTypeRequiredDescription
emailstringyesUser email address
passwordstringnoAuto-generated random password if omitted
usernamestringnoCustom username, defaults to email local-part if omitted

Response (201):

{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}

Non-admin callers receive:

{
"error": "Registration is closed"
}

POST /api/v1/publish

Publish or update a document.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
slugstringnoCustom URL slug, auto-generated if omitted
namespacedbooleannoSet to true to publish to username/slug (requires username)
titlestringnoPage title, extracted from first # Title if omitted

Response (201 created / 200 updated):

{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}

POST /api/v1/publish (Guest)

No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
tempbooleanyesMust be true, otherwise returns 401
slugstringnoCustom slug, auto-generated if omitted
titlestringnoPage title

Response (201):

{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}

GET /api/v1/documents

List all documents for the current user.

curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"

Response (200):

{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}

Results are sorted by updatedAt descending. Namespace documents have a non-null username field.

DELETE /api/v1/documents

Delete a document.

# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"

Response (200):

{ "ok": true }

POST /api/v1/upload-image

Upload an image.

curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"

Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.

Response (200):

{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}

GET /:slug

View a published page.

curl https://openbird.jhao.space/my-page
# → HTML document

GET /:username/:slug

View a namespaced page.

curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document

Development

Local Development

# Start local Worker (with KV + R2 simulation)cd worker
wrangler dev
# → Ready on http://localhost:8787# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'

Point the CLI to your local Worker:

cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.md

Project Structure

pagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json

Tech Stack

LayerTechnology
CLINode.js 18+ ESM, zero dependencies
WorkerCloudflare Workers (V8 isolate)
Page StorageCloudflare R2
Index StorageCloudflare KV
Image StorageCloudflare R2
Deploymentwrangler CLI

License

MIT

About

Open-source, self-hosted Markdown publishing. One CLI command turns Markdown into a shareable web page — free forever on Cloudflare.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

OpenBird

Publish Markdown as shareable web pages with a single command.

An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.


Part 1: User Guide

Features

  • One command publishes Markdown to beautiful, permanent web pages
  • Zero-config temporary publishing (--temp, no login, auto-expires in 1h)
  • Local images auto-upload to cloud storage
  • username/slug namespace for permanent URLs
  • Completely free for personal/small team use (Cloudflare Free Tier)
  • Zero npm dependencies

Quick Start

Prerequisites

  • Node.js 18+

1. Install the CLI

git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0

2. Login

openbird login

Your browser will open a login page. Enter your credentials to get an API key, which is saved automatically.

No account? Use the demo account below, or deploy your own backend (see Part 2).

3. Publish Your First Document

echo"# Hello OpenBird"> hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lake

Open the URL in your browser to see the rendered page.

Don't want to log in? Use --temp for a 1-hour temporary page:

echo"# Quick Test"> /tmp/test.md
openbird publish --temp /tmp/test.md
# → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed

Demo Account

ItemValue
Usernamedemo
Passworddemo@123
Backend URLhttps://openbird.jhao.space

Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.

Command Reference

openbird login

Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.

openbird login

You can also provide the API key via environment variable (for CI/CD):

export OPENBIRD_API_KEY="ob_xxx"

openbird publish

Publish or update a Markdown document.

# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt
FlagDescription
--slug <value>Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens)
--namespacePublish to username/<slug> permanent URL, slug auto-allocated or specified with --slug
--tempTemporary publish, no login required, auto-expires in 1 hour

Output:

✨ Published → https://openbird.jhao.space/my-custom-url

Updating an existing document:

✓ Updated → https://openbird.jhao.space/my-custom-url

openbird list

List all documents published by the current user.

openbird list

Output:

 my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents

Namespace documents are shown as username/slug.

openbird remove

Delete a published document.

# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-page

Output:

✓ Removed my-custom-url

Configuration

Environment Variables

VariableDescriptionDefault
OPENBIRD_API_URLWorker API URLhttps://openbird.jhao.space (public instance)
OPENBIRD_API_KEYAPI Key (takes precedence over credentials file)None

Credentials File

The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.

# Manual setup
mkdir -p ~/.config/openbird
echo"ob_your_api_key">~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentials

Mapping File (.openbird)

When you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:

# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page

Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.


Part 2: Self-Hosting

Prerequisites

npm install -g wrangler
wrangler login

1. Deploy the Backend

Option 1: One-Click Script (Recommended)

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.sh

The script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.

Option 2: Manual Deployment

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above# [[kv_namespaces]]# binding = "USERS"# id = "your-id"# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.dev
Optional: Bind a Custom Domain

After deployment, you can bind a custom domain:

  1. Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
  2. Add a custom domain (e.g. openbird.yourdomain.com)

2. Point the CLI to Your Instance

export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"

3. Admin Account

Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.

Admins can create additional users with openbird register:

openbird register --email user@example.com --password "password" [--username custom-name]

Architecture

Overview

CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response

A single Cloudflare Worker handles everything: API, page serving, and image proxy.

Data Storage

StoragePurposeFree Tier
KV USERSUser accounts, API key hashes, email index1 GB
KV DOCSDocument metadata (slug, title, expiry)1 GB
R2 PAGESRendered HTML pages10 GB
R2 IMAGESUser-uploaded images10 GB

Markdown Rendering

The Worker has a built-in zero-dependency Markdown renderer supporting:

  • Headings (h1-h6)
  • Bold, italic, inline code
  • Links, images
  • Unordered and ordered lists
  • Blockquotes, horizontal rules
  • Tables
  • Fenced code blocks

Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.


API Documentation

All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).

POST /api/v1/register

Admin only. Creates a new user account. Requires the admin's API key.

curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'
FieldTypeRequiredDescription
emailstringyesUser email address
passwordstringnoAuto-generated random password if omitted
usernamestringnoCustom username, defaults to email local-part if omitted

Response (201):

{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}

Non-admin callers receive:

{
"error": "Registration is closed"
}

POST /api/v1/publish

Publish or update a document.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
slugstringnoCustom URL slug, auto-generated if omitted
namespacedbooleannoSet to true to publish to username/slug (requires username)
titlestringnoPage title, extracted from first # Title if omitted

Response (201 created / 200 updated):

{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}

POST /api/v1/publish (Guest)

No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
tempbooleanyesMust be true, otherwise returns 401
slugstringnoCustom slug, auto-generated if omitted
titlestringnoPage title

Response (201):

{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}

GET /api/v1/documents

List all documents for the current user.

curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"

Response (200):

{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}

Results are sorted by updatedAt descending. Namespace documents have a non-null username field.

DELETE /api/v1/documents

Delete a document.

# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"

Response (200):

{ "ok": true }

POST /api/v1/upload-image

Upload an image.

curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"

Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.

Response (200):

{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}

GET /:slug

View a published page.

curl https://openbird.jhao.space/my-page
# → HTML document

GET /:username/:slug

View a namespaced page.

curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document

Development

Local Development

# Start local Worker (with KV + R2 simulation)cd worker
wrangler dev
# → Ready on http://localhost:8787# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'

Point the CLI to your local Worker:

cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.md

Project Structure

pagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json

Tech Stack

LayerTechnology
CLINode.js 18+ ESM, zero dependencies
WorkerCloudflare Workers (V8 isolate)
Page StorageCloudflare R2
Index StorageCloudflare KV
Image StorageCloudflare R2
Deploymentwrangler CLI

License

MIT

About

Open-source, self-hosted Markdown publishing. One CLI command turns Markdown into a shareable web page — free forever on Cloudflare.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

OpenBird

Publish Markdown as shareable web pages with a single command.

An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.


Part 1: User Guide

Features

  • One command publishes Markdown to beautiful, permanent web pages
  • Zero-config temporary publishing (--temp, no login, auto-expires in 1h)
  • Local images auto-upload to cloud storage
  • username/slug namespace for permanent URLs
  • Completely free for personal/small team use (Cloudflare Free Tier)
  • Zero npm dependencies

Quick Start

Prerequisites

  • Node.js 18+

1. Install the CLI

git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0

2. Login

openbird login

Your browser will open a login page. Enter your credentials to get an API key, which is saved automatically.

No account? Use the demo account below, or deploy your own backend (see Part 2).

3. Publish Your First Document

echo"# Hello OpenBird"> hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lake

Open the URL in your browser to see the rendered page.

Don't want to log in? Use --temp for a 1-hour temporary page:

echo"# Quick Test"> /tmp/test.md
openbird publish --temp /tmp/test.md
# → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed

Demo Account

ItemValue
Usernamedemo
Passworddemo@123
Backend URLhttps://openbird.jhao.space

Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.

Command Reference

openbird login

Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.

openbird login

You can also provide the API key via environment variable (for CI/CD):

export OPENBIRD_API_KEY="ob_xxx"

openbird publish

Publish or update a Markdown document.

# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt
FlagDescription
--slug <value>Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens)
--namespacePublish to username/<slug> permanent URL, slug auto-allocated or specified with --slug
--tempTemporary publish, no login required, auto-expires in 1 hour

Output:

✨ Published → https://openbird.jhao.space/my-custom-url

Updating an existing document:

✓ Updated → https://openbird.jhao.space/my-custom-url

openbird list

List all documents published by the current user.

openbird list

Output:

 my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents

Namespace documents are shown as username/slug.

openbird remove

Delete a published document.

# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-page

Output:

✓ Removed my-custom-url

Configuration

Environment Variables

VariableDescriptionDefault
OPENBIRD_API_URLWorker API URLhttps://openbird.jhao.space (public instance)
OPENBIRD_API_KEYAPI Key (takes precedence over credentials file)None

Credentials File

The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.

# Manual setup
mkdir -p ~/.config/openbird
echo"ob_your_api_key">~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentials

Mapping File (.openbird)

When you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:

# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page

Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.


Part 2: Self-Hosting

Prerequisites

npm install -g wrangler
wrangler login

1. Deploy the Backend

Option 1: One-Click Script (Recommended)

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.sh

The script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.

Option 2: Manual Deployment

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above# [[kv_namespaces]]# binding = "USERS"# id = "your-id"# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.dev
Optional: Bind a Custom Domain

After deployment, you can bind a custom domain:

  1. Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
  2. Add a custom domain (e.g. openbird.yourdomain.com)

2. Point the CLI to Your Instance

export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"

3. Admin Account

Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.

Admins can create additional users with openbird register:

openbird register --email user@example.com --password "password" [--username custom-name]

Architecture

Overview

CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response

A single Cloudflare Worker handles everything: API, page serving, and image proxy.

Data Storage

StoragePurposeFree Tier
KV USERSUser accounts, API key hashes, email index1 GB
KV DOCSDocument metadata (slug, title, expiry)1 GB
R2 PAGESRendered HTML pages10 GB
R2 IMAGESUser-uploaded images10 GB

Markdown Rendering

The Worker has a built-in zero-dependency Markdown renderer supporting:

  • Headings (h1-h6)
  • Bold, italic, inline code
  • Links, images
  • Unordered and ordered lists
  • Blockquotes, horizontal rules
  • Tables
  • Fenced code blocks

Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.


API Documentation

All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).

POST /api/v1/register

Admin only. Creates a new user account. Requires the admin's API key.

curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'
FieldTypeRequiredDescription
emailstringyesUser email address
passwordstringnoAuto-generated random password if omitted
usernamestringnoCustom username, defaults to email local-part if omitted

Response (201):

{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}

Non-admin callers receive:

{
"error": "Registration is closed"
}

POST /api/v1/publish

Publish or update a document.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
slugstringnoCustom URL slug, auto-generated if omitted
namespacedbooleannoSet to true to publish to username/slug (requires username)
titlestringnoPage title, extracted from first # Title if omitted

Response (201 created / 200 updated):

{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}

POST /api/v1/publish (Guest)

No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
tempbooleanyesMust be true, otherwise returns 401
slugstringnoCustom slug, auto-generated if omitted
titlestringnoPage title

Response (201):

{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}

GET /api/v1/documents

List all documents for the current user.

curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"

Response (200):

{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}

Results are sorted by updatedAt descending. Namespace documents have a non-null username field.

DELETE /api/v1/documents

Delete a document.

# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"

Response (200):

{ "ok": true }

POST /api/v1/upload-image

Upload an image.

curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"

Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.

Response (200):

{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}

GET /:slug

View a published page.

curl https://openbird.jhao.space/my-page
# → HTML document

GET /:username/:slug

View a namespaced page.

curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document

Development

Local Development

# Start local Worker (with KV + R2 simulation)cd worker
wrangler dev
# → Ready on http://localhost:8787# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'

Point the CLI to your local Worker:

cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.md

Project Structure

pagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json

Tech Stack

LayerTechnology
CLINode.js 18+ ESM, zero dependencies
WorkerCloudflare Workers (V8 isolate)
Page StorageCloudflare R2
Index StorageCloudflare KV
Image StorageCloudflare R2
Deploymentwrangler CLI

License

MIT

About

Open-source, self-hosted Markdown publishing. One CLI command turns Markdown into a shareable web page — free forever on Cloudflare.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

OpenBird

Publish Markdown as shareable web pages with a single command.

An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.


Part 1: User Guide

Features

  • One command publishes Markdown to beautiful, permanent web pages
  • Zero-config temporary publishing (--temp, no login, auto-expires in 1h)
  • Local images auto-upload to cloud storage
  • username/slug namespace for permanent URLs
  • Completely free for personal/small team use (Cloudflare Free Tier)
  • Zero npm dependencies

Quick Start

Prerequisites

  • Node.js 18+

1. Install the CLI

git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0

2. Login

openbird login

Your browser will open a login page. Enter your credentials to get an API key, which is saved automatically.

No account? Use the demo account below, or deploy your own backend (see Part 2).

3. Publish Your First Document

echo"# Hello OpenBird"> hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lake

Open the URL in your browser to see the rendered page.

Don't want to log in? Use --temp for a 1-hour temporary page:

echo"# Quick Test"> /tmp/test.md
openbird publish --temp /tmp/test.md
# → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed

Demo Account

ItemValue
Usernamedemo
Passworddemo@123
Backend URLhttps://openbird.jhao.space

Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.

Command Reference

openbird login

Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.

openbird login

You can also provide the API key via environment variable (for CI/CD):

export OPENBIRD_API_KEY="ob_xxx"

openbird publish

Publish or update a Markdown document.

# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt
FlagDescription
--slug <value>Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens)
--namespacePublish to username/<slug> permanent URL, slug auto-allocated or specified with --slug
--tempTemporary publish, no login required, auto-expires in 1 hour

Output:

✨ Published → https://openbird.jhao.space/my-custom-url

Updating an existing document:

✓ Updated → https://openbird.jhao.space/my-custom-url

openbird list

List all documents published by the current user.

openbird list

Output:

 my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents

Namespace documents are shown as username/slug.

openbird remove

Delete a published document.

# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-page

Output:

✓ Removed my-custom-url

Configuration

Environment Variables

VariableDescriptionDefault
OPENBIRD_API_URLWorker API URLhttps://openbird.jhao.space (public instance)
OPENBIRD_API_KEYAPI Key (takes precedence over credentials file)None

Credentials File

The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.

# Manual setup
mkdir -p ~/.config/openbird
echo"ob_your_api_key">~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentials

Mapping File (.openbird)

When you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:

# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page

Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.


Part 2: Self-Hosting

Prerequisites

npm install -g wrangler
wrangler login

1. Deploy the Backend

Option 1: One-Click Script (Recommended)

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.sh

The script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.

Option 2: Manual Deployment

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above# [[kv_namespaces]]# binding = "USERS"# id = "your-id"# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.dev
Optional: Bind a Custom Domain

After deployment, you can bind a custom domain:

  1. Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
  2. Add a custom domain (e.g. openbird.yourdomain.com)

2. Point the CLI to Your Instance

export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"

3. Admin Account

Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.

Admins can create additional users with openbird register:

openbird register --email user@example.com --password "password" [--username custom-name]

Architecture

Overview

CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response

A single Cloudflare Worker handles everything: API, page serving, and image proxy.

Data Storage

StoragePurposeFree Tier
KV USERSUser accounts, API key hashes, email index1 GB
KV DOCSDocument metadata (slug, title, expiry)1 GB
R2 PAGESRendered HTML pages10 GB
R2 IMAGESUser-uploaded images10 GB

Markdown Rendering

The Worker has a built-in zero-dependency Markdown renderer supporting:

  • Headings (h1-h6)
  • Bold, italic, inline code
  • Links, images
  • Unordered and ordered lists
  • Blockquotes, horizontal rules
  • Tables
  • Fenced code blocks

Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.


API Documentation

All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).

POST /api/v1/register

Admin only. Creates a new user account. Requires the admin's API key.

curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'
FieldTypeRequiredDescription
emailstringyesUser email address
passwordstringnoAuto-generated random password if omitted
usernamestringnoCustom username, defaults to email local-part if omitted

Response (201):

{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}

Non-admin callers receive:

{
"error": "Registration is closed"
}

POST /api/v1/publish

Publish or update a document.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
slugstringnoCustom URL slug, auto-generated if omitted
namespacedbooleannoSet to true to publish to username/slug (requires username)
titlestringnoPage title, extracted from first # Title if omitted

Response (201 created / 200 updated):

{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}

POST /api/v1/publish (Guest)

No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
tempbooleanyesMust be true, otherwise returns 401
slugstringnoCustom slug, auto-generated if omitted
titlestringnoPage title

Response (201):

{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}

GET /api/v1/documents

List all documents for the current user.

curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"

Response (200):

{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}

Results are sorted by updatedAt descending. Namespace documents have a non-null username field.

DELETE /api/v1/documents

Delete a document.

# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"

Response (200):

{ "ok": true }

POST /api/v1/upload-image

Upload an image.

curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"

Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.

Response (200):

{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}

GET /:slug

View a published page.

curl https://openbird.jhao.space/my-page
# → HTML document

GET /:username/:slug

View a namespaced page.

curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document

Development

Local Development

# Start local Worker (with KV + R2 simulation)cd worker
wrangler dev
# → Ready on http://localhost:8787# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'

Point the CLI to your local Worker:

cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.md

Project Structure

pagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json

Tech Stack

LayerTechnology
CLINode.js 18+ ESM, zero dependencies
WorkerCloudflare Workers (V8 isolate)
Page StorageCloudflare R2
Index StorageCloudflare KV
Image StorageCloudflare R2
Deploymentwrangler CLI

License

MIT

About

Open-source, self-hosted Markdown publishing. One CLI command turns Markdown into a shareable web page — free forever on Cloudflare.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

OpenBird

Publish Markdown as shareable web pages with a single command.

An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.


Part 1: User Guide

Features

  • One command publishes Markdown to beautiful, permanent web pages
  • Zero-config temporary publishing (--temp, no login, auto-expires in 1h)
  • Local images auto-upload to cloud storage
  • username/slug namespace for permanent URLs
  • Completely free for personal/small team use (Cloudflare Free Tier)
  • Zero npm dependencies

Quick Start

Prerequisites

  • Node.js 18+

1. Install the CLI

git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0

2. Login

openbird login

Your browser will open a login page. Enter your credentials to get an API key, which is saved automatically.

No account? Use the demo account below, or deploy your own backend (see Part 2).

3. Publish Your First Document

echo"# Hello OpenBird"> hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lake

Open the URL in your browser to see the rendered page.

Don't want to log in? Use --temp for a 1-hour temporary page:

echo"# Quick Test"> /tmp/test.md
openbird publish --temp /tmp/test.md
# → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed

Demo Account

ItemValue
Usernamedemo
Passworddemo@123
Backend URLhttps://openbird.jhao.space

Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.

Command Reference

openbird login

Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.

openbird login

You can also provide the API key via environment variable (for CI/CD):

export OPENBIRD_API_KEY="ob_xxx"

openbird publish

Publish or update a Markdown document.

# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt
FlagDescription
--slug <value>Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens)
--namespacePublish to username/<slug> permanent URL, slug auto-allocated or specified with --slug
--tempTemporary publish, no login required, auto-expires in 1 hour

Output:

✨ Published → https://openbird.jhao.space/my-custom-url

Updating an existing document:

✓ Updated → https://openbird.jhao.space/my-custom-url

openbird list

List all documents published by the current user.

openbird list

Output:

 my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents

Namespace documents are shown as username/slug.

openbird remove

Delete a published document.

# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-page

Output:

✓ Removed my-custom-url

Configuration

Environment Variables

VariableDescriptionDefault
OPENBIRD_API_URLWorker API URLhttps://openbird.jhao.space (public instance)
OPENBIRD_API_KEYAPI Key (takes precedence over credentials file)None

Credentials File

The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.

# Manual setup
mkdir -p ~/.config/openbird
echo"ob_your_api_key">~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentials

Mapping File (.openbird)

When you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:

# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page

Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.


Part 2: Self-Hosting

Prerequisites

npm install -g wrangler
wrangler login

1. Deploy the Backend

Option 1: One-Click Script (Recommended)

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.sh

The script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.

Option 2: Manual Deployment

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above# [[kv_namespaces]]# binding = "USERS"# id = "your-id"# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.dev
Optional: Bind a Custom Domain

After deployment, you can bind a custom domain:

  1. Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
  2. Add a custom domain (e.g. openbird.yourdomain.com)

2. Point the CLI to Your Instance

export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"

3. Admin Account

Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.

Admins can create additional users with openbird register:

openbird register --email user@example.com --password "password" [--username custom-name]

Architecture

Overview

CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response

A single Cloudflare Worker handles everything: API, page serving, and image proxy.

Data Storage

StoragePurposeFree Tier
KV USERSUser accounts, API key hashes, email index1 GB
KV DOCSDocument metadata (slug, title, expiry)1 GB
R2 PAGESRendered HTML pages10 GB
R2 IMAGESUser-uploaded images10 GB

Markdown Rendering

The Worker has a built-in zero-dependency Markdown renderer supporting:

  • Headings (h1-h6)
  • Bold, italic, inline code
  • Links, images
  • Unordered and ordered lists
  • Blockquotes, horizontal rules
  • Tables
  • Fenced code blocks

Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.


API Documentation

All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).

POST /api/v1/register

Admin only. Creates a new user account. Requires the admin's API key.

curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'
FieldTypeRequiredDescription
emailstringyesUser email address
passwordstringnoAuto-generated random password if omitted
usernamestringnoCustom username, defaults to email local-part if omitted

Response (201):

{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}

Non-admin callers receive:

{
"error": "Registration is closed"
}

POST /api/v1/publish

Publish or update a document.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
slugstringnoCustom URL slug, auto-generated if omitted
namespacedbooleannoSet to true to publish to username/slug (requires username)
titlestringnoPage title, extracted from first # Title if omitted

Response (201 created / 200 updated):

{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}

POST /api/v1/publish (Guest)

No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
tempbooleanyesMust be true, otherwise returns 401
slugstringnoCustom slug, auto-generated if omitted
titlestringnoPage title

Response (201):

{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}

GET /api/v1/documents

List all documents for the current user.

curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"

Response (200):

{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}

Results are sorted by updatedAt descending. Namespace documents have a non-null username field.

DELETE /api/v1/documents

Delete a document.

# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"

Response (200):

{ "ok": true }

POST /api/v1/upload-image

Upload an image.

curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"

Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.

Response (200):

{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}

GET /:slug

View a published page.

curl https://openbird.jhao.space/my-page
# → HTML document

GET /:username/:slug

View a namespaced page.

curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document

Development

Local Development

# Start local Worker (with KV + R2 simulation)cd worker
wrangler dev
# → Ready on http://localhost:8787# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'

Point the CLI to your local Worker:

cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.md

Project Structure

pagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json

Tech Stack

LayerTechnology
CLINode.js 18+ ESM, zero dependencies
WorkerCloudflare Workers (V8 isolate)
Page StorageCloudflare R2
Index StorageCloudflare KV
Image StorageCloudflare R2
Deploymentwrangler CLI

License

MIT

About

Open-source, self-hosted Markdown publishing. One CLI command turns Markdown into a shareable web page — free forever on Cloudflare.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

OpenBird

Publish Markdown as shareable web pages with a single command.

An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.


Part 1: User Guide

Features

  • One command publishes Markdown to beautiful, permanent web pages
  • Zero-config temporary publishing (--temp, no login, auto-expires in 1h)
  • Local images auto-upload to cloud storage
  • username/slug namespace for permanent URLs
  • Completely free for personal/small team use (Cloudflare Free Tier)
  • Zero npm dependencies

Quick Start

Prerequisites

  • Node.js 18+

1. Install the CLI

git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0

2. Login

openbird login

Your browser will open a login page. Enter your credentials to get an API key, which is saved automatically.

No account? Use the demo account below, or deploy your own backend (see Part 2).

3. Publish Your First Document

echo"# Hello OpenBird"> hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lake

Open the URL in your browser to see the rendered page.

Don't want to log in? Use --temp for a 1-hour temporary page:

echo"# Quick Test"> /tmp/test.md
openbird publish --temp /tmp/test.md
# → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed

Demo Account

ItemValue
Usernamedemo
Passworddemo@123
Backend URLhttps://openbird.jhao.space

Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.

Command Reference

openbird login

Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.

openbird login

You can also provide the API key via environment variable (for CI/CD):

export OPENBIRD_API_KEY="ob_xxx"

openbird publish

Publish or update a Markdown document.

# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt
FlagDescription
--slug <value>Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens)
--namespacePublish to username/<slug> permanent URL, slug auto-allocated or specified with --slug
--tempTemporary publish, no login required, auto-expires in 1 hour

Output:

✨ Published → https://openbird.jhao.space/my-custom-url

Updating an existing document:

✓ Updated → https://openbird.jhao.space/my-custom-url

openbird list

List all documents published by the current user.

openbird list

Output:

 my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents

Namespace documents are shown as username/slug.

openbird remove

Delete a published document.

# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-page

Output:

✓ Removed my-custom-url

Configuration

Environment Variables

VariableDescriptionDefault
OPENBIRD_API_URLWorker API URLhttps://openbird.jhao.space (public instance)
OPENBIRD_API_KEYAPI Key (takes precedence over credentials file)None

Credentials File

The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.

# Manual setup
mkdir -p ~/.config/openbird
echo"ob_your_api_key">~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentials

Mapping File (.openbird)

When you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:

# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page

Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.


Part 2: Self-Hosting

Prerequisites

npm install -g wrangler
wrangler login

1. Deploy the Backend

Option 1: One-Click Script (Recommended)

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.sh

The script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.

Option 2: Manual Deployment

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above# [[kv_namespaces]]# binding = "USERS"# id = "your-id"# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.dev
Optional: Bind a Custom Domain

After deployment, you can bind a custom domain:

  1. Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
  2. Add a custom domain (e.g. openbird.yourdomain.com)

2. Point the CLI to Your Instance

export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"

3. Admin Account

Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.

Admins can create additional users with openbird register:

openbird register --email user@example.com --password "password" [--username custom-name]

Architecture

Overview

CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response

A single Cloudflare Worker handles everything: API, page serving, and image proxy.

Data Storage

StoragePurposeFree Tier
KV USERSUser accounts, API key hashes, email index1 GB
KV DOCSDocument metadata (slug, title, expiry)1 GB
R2 PAGESRendered HTML pages10 GB
R2 IMAGESUser-uploaded images10 GB

Markdown Rendering

The Worker has a built-in zero-dependency Markdown renderer supporting:

  • Headings (h1-h6)
  • Bold, italic, inline code
  • Links, images
  • Unordered and ordered lists
  • Blockquotes, horizontal rules
  • Tables
  • Fenced code blocks

Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.


API Documentation

All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).

POST /api/v1/register

Admin only. Creates a new user account. Requires the admin's API key.

curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'
FieldTypeRequiredDescription
emailstringyesUser email address
passwordstringnoAuto-generated random password if omitted
usernamestringnoCustom username, defaults to email local-part if omitted

Response (201):

{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}

Non-admin callers receive:

{
"error": "Registration is closed"
}

POST /api/v1/publish

Publish or update a document.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
slugstringnoCustom URL slug, auto-generated if omitted
namespacedbooleannoSet to true to publish to username/slug (requires username)
titlestringnoPage title, extracted from first # Title if omitted

Response (201 created / 200 updated):

{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}

POST /api/v1/publish (Guest)

No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
tempbooleanyesMust be true, otherwise returns 401
slugstringnoCustom slug, auto-generated if omitted
titlestringnoPage title

Response (201):

{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}

GET /api/v1/documents

List all documents for the current user.

curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"

Response (200):

{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}

Results are sorted by updatedAt descending. Namespace documents have a non-null username field.

DELETE /api/v1/documents

Delete a document.

# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"

Response (200):

{ "ok": true }

POST /api/v1/upload-image

Upload an image.

curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"

Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.

Response (200):

{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}

GET /:slug

View a published page.

curl https://openbird.jhao.space/my-page
# → HTML document

GET /:username/:slug

View a namespaced page.

curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document

Development

Local Development

# Start local Worker (with KV + R2 simulation)cd worker
wrangler dev
# → Ready on http://localhost:8787# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'

Point the CLI to your local Worker:

cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.md

Project Structure

pagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json

Tech Stack

LayerTechnology
CLINode.js 18+ ESM, zero dependencies
WorkerCloudflare Workers (V8 isolate)
Page StorageCloudflare R2
Index StorageCloudflare KV
Image StorageCloudflare R2
Deploymentwrangler CLI

License

MIT

About

Open-source, self-hosted Markdown publishing. One CLI command turns Markdown into a shareable web page — free forever on Cloudflare.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

OpenBird

Publish Markdown as shareable web pages with a single command.

An open-source alternative to JotBird, self-hosted on Cloudflare's free tier.


Part 1: User Guide

Features

  • One command publishes Markdown to beautiful, permanent web pages
  • Zero-config temporary publishing (--temp, no login, auto-expires in 1h)
  • Local images auto-upload to cloud storage
  • username/slug namespace for permanent URLs
  • Completely free for personal/small team use (Cloudflare Free Tier)
  • Zero npm dependencies

Quick Start

Prerequisites

  • Node.js 18+

1. Install the CLI

git clone https://github.com/PPsteven/openbird.git
cd openbird/cli
npm link
# Verify installation
openbird --version
# → openbird v0.1.0

2. Login

openbird login

Your browser will open a login page. Enter your credentials to get an API key, which is saved automatically.

No account? Use the demo account below, or deploy your own backend (see Part 2).

3. Publish Your First Document

echo"# Hello OpenBird"> hello.md
openbird publish hello.md
# → ✨ Published → https://openbird.jhao.space/quiet-blue-lake

Open the URL in your browser to see the rendered page.

Don't want to log in? Use --temp for a 1-hour temporary page:

echo"# Quick Test"> /tmp/test.md
openbird publish --temp /tmp/test.md
# → ⚡ Published (temp, 1h) → https://openbird.jhao.space/warm-clear-seed

Demo Account

ItemValue
Usernamedemo
Passworddemo@123
Backend URLhttps://openbird.jhao.space

Log in with the demo account to try all features. All documents are publicly visible — do not publish sensitive content.

Command Reference

openbird login

Authenticate the CLI. The token is saved to ~/.config/openbird/credentials.

openbird login

You can also provide the API key via environment variable (for CI/CD):

export OPENBIRD_API_KEY="ob_xxx"

openbird publish

Publish or update a Markdown document.

# Publish a file
openbird publish my-doc.md
# Custom URL slug
openbird publish --slug my-custom-url my-doc.md
# Publish to namespace (permanent URL, slug auto-allocated)
openbird publish --namespace my-doc.md
# Namespace + custom slug
openbird publish --slug my-page --namespace my-doc.md
# Temporary publish (no login, auto-expires in 1 hour)
openbird publish --temp my-doc.md
# Publish from stdin
cat my-doc.md | openbird publish
# Supported file formats: .md .markdown .mdx .txt .text
openbird publish notes.txt
FlagDescription
--slug <value>Custom URL slug (e.g. my-page, 3-60 chars, lowercase alphanumeric and hyphens)
--namespacePublish to username/<slug> permanent URL, slug auto-allocated or specified with --slug
--tempTemporary publish, no login required, auto-expires in 1 hour

Output:

✨ Published → https://openbird.jhao.space/my-custom-url

Updating an existing document:

✓ Updated → https://openbird.jhao.space/my-custom-url

openbird list

List all documents published by the current user.

openbird list

Output:

 my-custom-url My Document Title
https://openbird.jhao.space/my-custom-url
ppsteven/my-page My Page
https://openbird.jhao.space/ppsteven/my-page
2 documents

Namespace documents are shown as username/slug.

openbird remove

Delete a published document.

# By filename (looks up from .openbird mapping)
openbird remove my-doc.md
# By slug directly
openbird remove my-custom-url
# Delete a namespaced document
openbird remove --namespace my-page
# Or pass username/slug directly
openbird remove ppsteven/my-page

Output:

✓ Removed my-custom-url

Configuration

Environment Variables

VariableDescriptionDefault
OPENBIRD_API_URLWorker API URLhttps://openbird.jhao.space (public instance)
OPENBIRD_API_KEYAPI Key (takes precedence over credentials file)None

Credentials File

The API key is stored in ~/.config/openbird/credentials with permissions 0600. Managed automatically by openbird login.

# Manual setup
mkdir -p ~/.config/openbird
echo"ob_your_api_key">~/.config/openbird/credentials
chmod 600 ~/.config/openbird/credentials

Mapping File (.openbird)

When you publish a file, the CLI creates a .openbird file in the current directory to track filename-to-slug mappings:

# .openbird
my-doc.md = my-custom-url
about.md = ppsteven/my-page

Subsequent openbird publish my-doc.md calls will automatically update the same URL without needing --slug.


Part 2: Self-Hosting

Prerequisites

npm install -g wrangler
wrangler login

1. Deploy the Backend

Option 1: One-Click Script (Recommended)

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Configure environment variables
cp .env.example .env
# Edit .env, set your domain:# Custom domain: OPENBIRD_DOMAIN=openbird.yourdomain.com# workers.dev: OPENBIRD_DOMAIN=openbird.yoursubdomain.workers.dev
chmod +x deploy.sh
./deploy.sh

The script automatically creates KV namespaces, R2 buckets, generates wrangler.toml, and deploys.

Option 2: Manual Deployment

git clone https://github.com/PPsteven/openbird.git
cd openbird/worker
# Create KV namespaces (note the output ids)
wrangler kv namespace create USERS
wrangler kv namespace create DOCS
# Create R2 buckets
wrangler r2 bucket create openbird-pages
wrangler r2 bucket create openbird-images
# Edit wrangler.toml, fill in the KV namespace ids above# [[kv_namespaces]]# binding = "USERS"# id = "your-id"# Deploy
wrangler deploy
# → Deployed "openbird" → https://openbird.your-subdomain.workers.dev
Optional: Bind a Custom Domain

After deployment, you can bind a custom domain:

  1. Cloudflare Dashboard → Workers & Pages → openbird → Settings → Domains & Routes
  2. Add a custom domain (e.g. openbird.yourdomain.com)

2. Point the CLI to Your Instance

export OPENBIRD_API_URL="https://openbird.your-subdomain.workers.dev"

3. Admin Account

Set ADMIN_EMAIL and ADMIN_PASSWORD in .env before deployment. The admin account is auto-created on the first request.

Admins can create additional users with openbird register:

openbird register --email user@example.com --password "password" [--username custom-name]

Architecture

Overview

CLI → api.js → Worker /api/v1/*
↓
KV (USERS + DOCS index)
R2 (PAGES + IMAGES)
↓
Browser → Worker /:slug → R2 → HTML response

A single Cloudflare Worker handles everything: API, page serving, and image proxy.

Data Storage

StoragePurposeFree Tier
KV USERSUser accounts, API key hashes, email index1 GB
KV DOCSDocument metadata (slug, title, expiry)1 GB
R2 PAGESRendered HTML pages10 GB
R2 IMAGESUser-uploaded images10 GB

Markdown Rendering

The Worker has a built-in zero-dependency Markdown renderer supporting:

  • Headings (h1-h6)
  • Bold, italic, inline code
  • Links, images
  • Unordered and ordered lists
  • Blockquotes, horizontal rules
  • Tables
  • Fenced code blocks

Pages are returned as complete HTML documents with inline CSS, viewable directly in any browser.


API Documentation

All API endpoints require Authorization: Bearer ob_xxx header (except guest publish).

POST /api/v1/register

Admin only. Creates a new user account. Requires the admin's API key.

curl -X POST https://openbird.jhao.space/api/v1/register \
-H "Authorization: Bearer ob_admin_api_key" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"your-password","username":"optional-username"}'
FieldTypeRequiredDescription
emailstringyesUser email address
passwordstringnoAuto-generated random password if omitted
usernamestringnoCustom username, defaults to email local-part if omitted

Response (201):

{
"userId": "user_a1b2c3d4e5f6",
"apiKey": "ob_xxx...",
"email": "user@example.com",
"username": "optional-username"
}

Non-admin callers receive:

{
"error": "Registration is closed"
}

POST /api/v1/publish

Publish or update a document.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Authorization: Bearer ob_xxx" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello\n\nWorld","slug":"my-page"}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
slugstringnoCustom URL slug, auto-generated if omitted
namespacedbooleannoSet to true to publish to username/slug (requires username)
titlestringnoPage title, extracted from first # Title if omitted

Response (201 created / 200 updated):

{
"slug": "my-page",
"username": null,
"url": "https://openbird.jhao.space/my-page",
"title": "Hello",
"created": true
}

POST /api/v1/publish (Guest)

No authentication required. Publishes a 1-hour temporary page. Must pass temp: true.

curl -X POST https://openbird.jhao.space/api/v1/publish \
-H "Content-Type: application/json" \
-d '{"markdown":"# Hello Guest","temp":true}'
FieldTypeRequiredDescription
markdownstringyesMarkdown content (max 256KB)
tempbooleanyesMust be true, otherwise returns 401
slugstringnoCustom slug, auto-generated if omitted
titlestringnoPage title

Response (201):

{
"slug": "warm-clear-seed",
"url": "https://openbird.jhao.space/warm-clear-seed",
"title": "Hello Guest",
"expiresAt": "2026-07-04T11:00:00.000Z",
"ttlMinutes": 60,
"guest": true
}

GET /api/v1/documents

List all documents for the current user.

curl https://openbird.jhao.space/api/v1/documents \
-H "Authorization: Bearer ob_xxx"

Response (200):

{
"documents": [
{
"slug": "my-page",
"username": null,
"title": "Hello",
"url": "https://openbird.jhao.space/my-page",
"source": "api",
"updatedAt": "2026-07-04T10:00:00.000Z",
"expiresAt": null
}
]
}

Results are sorted by updatedAt descending. Namespace documents have a non-null username field.

DELETE /api/v1/documents

Delete a document.

# Delete a regular document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page" \
-H "Authorization: Bearer ob_xxx"# Delete a namespaced document
curl -X DELETE "https://openbird.jhao.space/api/v1/documents?slug=my-page&namespaced=true" \
-H "Authorization: Bearer ob_xxx"

Response (200):

{ "ok": true }

POST /api/v1/upload-image

Upload an image.

curl -X POST https://openbird.jhao.space/api/v1/upload-image \
-H "Authorization: Bearer ob_xxx" \
-F "file=@photo.png"

Supported formats: png, jpeg, gif, webp, svg. Max 10 MB.

Response (200):

{
"url": "https://openbird.jhao.space/images/user_abc123/a1b2c3d4.png"
}

GET /:slug

View a published page.

curl https://openbird.jhao.space/my-page
# → HTML document

GET /:username/:slug

View a namespaced page.

curl https://openbird.jhao.space/ppsteven/my-page
# → HTML document

Development

Local Development

# Start local Worker (with KV + R2 simulation)cd worker
wrangler dev
# → Ready on http://localhost:8787# In another terminal, test
curl -X POST http://localhost:8787/api/v1/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"123456"}'

Point the CLI to your local Worker:

cd cli
export OPENBIRD_API_URL="http://localhost:8787"
node src/cli.js publish test.md

Project Structure

pagebird/
├── AGENTS.md # AI Agent rules & conventions
├── README.md # Project documentation (English)
├── README.zh.md # Project documentation (Chinese)
├── docs/ # Design documents
│ ├── D0-reference.md # JotBird reverse engineering reference
│ ├── D1-worker-core.md # Worker backend spec
│ ├── D2-cli-core.md # CLI core spec
│ ├── D3-images.md # Image upload spec
│ ├── D4-namespace.md # Namespace spec
│ ├── D5-deployment.md # Deployment verification spec
│ ├── D6-documentation.md # Documentation spec
│ ├── architecture.md # Architecture & decisions
│ ├── status.md # Progress tracking
│ └── troubleshoot.md # Troubleshooting guide
├── worker/ # Cloudflare Worker
│ ├── src/index.js # Worker main program
│ ├── wrangler.toml # Worker configuration
│ └── package.json
└── cli/ # CLI tool
├── src/
│ ├── cli.js # CLI entry point
│ ├── api.js # API client
│ ├── config.js # Configuration management
│ ├── files.js # File type validation
│ ├── images.js # Image upload & rewriting
│ ├── login.js # Login flow
│ └── mapping.js # .openbird mapping management
└── package.json

Tech Stack

LayerTechnology
CLINode.js 18+ ESM, zero dependencies
WorkerCloudflare Workers (V8 isolate)
Page StorageCloudflare R2
Index StorageCloudflare KV
Image StorageCloudflare R2
Deploymentwrangler CLI

License

MIT

About

Open-source, self-hosted Markdown publishing. One CLI command turns Markdown into a shareable web page — free forever on Cloudflare.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages