ox-ghost: Org-mode to Ghost Lexical JSON Exporter
Overview
ox-ghost is an Emacs org-mode export backend that converts org files to Ghost's Lexical JSON format, enabling direct publishing to Ghost CMS.
Installation
Doom Emacs (packages.el)
(package! ox-ghost
:recipe (:host github :repo "ii/ox-ghost"
:files ("ox-ghost.el" "ox-ghost-publish.el")))
Then in config.el:
(use-package! ox-ghost
:after org
:config
;; Optional: Set path to ghost.js for publishing
(setq ghost-publish-script "/path/to/ox-ghost/ghost.js"))
Straight.el / use-package
(use-package ox-ghost
:straight (:host github :repo "ii/ox-ghost"
:files ("ox-ghost.el" "ox-ghost-publish.el"))
:after org)
Manual Installation
(add-to-list 'load-path "/path/to/ox-ghost")
(require 'ox-ghost)
(require 'ox-ghost-publish) ; Optional: for publishing workflow
Node.js Validator (optional)
For local validation with Ghost's renderer:
cd /path/to/ox-ghost
npm install
Usage
Interactive
M-x org-lexical-export-as-json- Export to bufferM-x org-lexical-export-to-file- Export to .json file
Shell Script
# Export org file to JSON
./org-to-lexical.sh input.org output.json
Batch Mode (direct emacs)
emacs --batch -Q \
--eval "(require 'org)" \
--eval "(require 'ox-html)" \
-l ox-ghost.el \
--visit input.org \
--eval "(princ (org-export-as 'lexical))"
Validation
Validate exported JSON using Ghost's actual renderer:
# Validate JSON
node validate-lexical.js output.json
# Validate and generate HTML preview
node validate-lexical.js output.json --html preview.html
# Validate directly from org file (exports first)
node validate-lexical.js input.org --html preview.html
# Quiet mode for scripting (outputs JSON stats)
node validate-lexical.js output.json --quiet
Supported Node Types
Standard Org Elements → Lexical Nodes
| Org Element | Lexical Node | Notes |
|---|---|---|
* Heading |
heading | Level 1 → h2, Level 2 → h3 etc |
| Paragraph | paragraph | With inline formatting |
*bold* |
text (format=1) | Bitmask format |
/italic/ |
text (format=2) | |
_underline_ |
text (format=8) | |
+strikethrough+ |
text (format=4) | |
=code= |
text (format=16) | Inline code |
[[url][desc]] |
link | With nested text children |
[[file:img.jpg]] |
image | Detected by extension |
- item |
list/listitem | bullet or number listType |
----- |
horizontalrule | |
#+BEGIN_QUOTE |
quote | |
#+BEGIN_SRC |
codeblock | With language |
#+BEGIN_EXAMPLE |
codeblock | language=“text” |
#+BEGIN_EXPORT |
html/raw | HTML or raw Lexical JSON |
Special Blocks → Ghost Cards
Callout
#+BEGIN_CALLOUT :emoji 🎉 :color green
Your callout text here.
#+END_CALLOUT
Properties:
:emoji- Emoji icon (default: 💡):color- Background color: blue, green, yellow, red, pink, purple, grey (default: blue)
Toggle
#+BEGIN_TOGGLE :heading "Click to expand"
Hidden content revealed on click.
#+END_TOGGLE
Properties:
:heading- Toggle header text (required)
Button
#+BEGIN_BUTTON :url https://example.com :alignment center
Button Text
#+END_BUTTON
Properties:
:url- Button link URL (required):alignment- left, center, right (default: center)
Aside
#+BEGIN_ASIDE
Secondary content in an aside.
#+END_ASIDE
Gallery
#+BEGIN_GALLERY :images "img1.jpg, img2.jpg, img3.jpg"
#+END_GALLERY
Properties:
:images- Comma-separated list of image URLs
Video
#+BEGIN_VIDEO :src https://example.com/video.mp4
#+END_VIDEO
Properties:
:src- Video URL (required)
Audio
#+BEGIN_AUDIO :src https://example.com/audio.mp3
Episode Title
#+END_AUDIO
Properties:
:src- Audio URL (required)
Embed
#+BEGIN_EMBED :url https://twitter.com/example/status/123
Tweet preview text
#+END_EMBED
Properties:
:url- Embed URL (required)
Bookmark
#+BEGIN_BOOKMARK :url https://example.com
Bookmark Title
#+END_BOOKMARK
Properties:
:url- Bookmark URL (required)
File Download
#+BEGIN_FILE :src https://example.com/doc.pdf :fileName "Document.pdf"
File description
#+END_FILE
Properties:
:src- File URL (required):fileName- Display filename
Product
#+BEGIN_PRODUCT :url https://shop.example.com :buttonText "Buy Now"
Product Name
#+END_PRODUCT
Properties:
:url- Product URL:buttonText- CTA button text
Signup Form
#+BEGIN_SIGNUP :layout regular :buttonText "Subscribe Now"
#+END_SIGNUP
Properties:
:layout- regular, wide, split (default: regular):buttonText- Button text (default: Subscribe)
Call to Action
#+BEGIN_CTA :layout minimal :buttonText "Learn More" :url https://example.com
Your CTA text here.
#+END_CTA
Properties:
:layout- minimal, immersive, split (default: minimal):buttonText- Button text (default: Learn more):url- Button link URL
Header Card
#+BEGIN_HEADER :size small
Header Text
#+END_HEADER
Properties:
:size- small, medium, large (default: small)
Transistor Podcast
#+BEGIN_TRANSISTOR :url https://share.transistor.fm/e/episode-id
#+END_TRANSISTOR
Properties:
:url- Transistor episode URL
Asciinema Player
Embed terminal recordings with the asciinema player.
#+BEGIN_ASCIINEMA :src https://asciinema.org/a/8.cast :speed 2 :theme monokai :title "Star Wars in ASCII"
#+END_ASCIINEMA
Properties:
:src- Path or URL to .cast file (required):speed- Playback speed multiplier (default: 1):theme- Player theme: asciinema, tango, solarized-dark, solarized-light, monokai, dracula, nord, github-dark, github-light:poster- Poster frame specification (e.g., "npt:0:30" for 30 seconds in):cols/:rows- Terminal dimensions (auto-detected if omitted):autoplay- Auto-play on load (default: false):loop- Loop playback (default: false):start-at- Start time in seconds:idle-time-limit- Max idle time between frames:fit- Fit mode: width, height, both, none (default: width):controls- Show controls: true, false, auto (default: true):title- Title shown above player
Media File Handling: Local cast files are tracked in metadata.json for upload to Ghost. When you reference a local file like :src ./recordings/demo.cast, ox-ghost will:
- Add it to the
media_fileslist in metadata - Transform the path to
/content/media/casts/demo.cast - Your publishing script uploads the file before creating the post
Email-only Content
#+BEGIN_EMAIL
Content only visible in email newsletters.
#+END_EMAIL
REPL Block (Code + Output)
Wrap source code with its output using configurable styles:
Simple (default)
#+BEGIN_REPL
#+BEGIN_SRC python
print("Hello!")
#+END_SRC
#+RESULTS:
: Hello!
#+END_REPL
Outputs consecutive codeblocks (source + output).
Labeled
#+BEGIN_REPL :style labeled :label "Result"
#+BEGIN_SRC python
x = 2 + 2
print(x)
#+END_SRC
#+RESULTS:
: 4
#+END_REPL
Adds a label paragraph before the output.
Callout
#+BEGIN_REPL :style callout :emoji 💻 :color green
#+BEGIN_SRC shell
uname -a
#+END_SRC
#+RESULTS:
: Linux host 6.1.0 x86_64
#+END_REPL
Wraps output in a colored callout box.
Toggle
#+BEGIN_REPL :style toggle :heading "Python Example"
#+BEGIN_SRC python
for i in range(3):
print(i)
#+END_SRC
#+RESULTS:
: 0
: 1
: 2
#+END_REPL
Puts code in a collapsible toggle, output follows after.
Aside
#+BEGIN_REPL :style aside
#+BEGIN_SRC elisp
(message "Hello!")
#+END_SRC
#+RESULTS:
: Hello!
#+END_REPL
Wraps everything in an aside block.
Properties:
:style- simple, labeled, callout, toggle, aside (default: simple):label- Label text for labeled style (default: "Output"):emoji- Emoji for callout style (default: 📤):color- Color for callout style (default: grey):heading- Heading for toggle style (default: "Code (language)")
Image Attributes
Use #+ATTR_LEXICAL to set image properties:
#+ATTR_LEXICAL: :cardWidth wide
[[file:photo.jpg][Alt text for the image]]
Properties:
:cardWidth- regular, wide, full (default: regular)
Raw Lexical JSON
Insert raw Lexical JSON directly:
#+BEGIN_EXPORT lexical
{"type":"paragraph","version":1,"children":[...]}
#+END_EXPORT
Format Bitmask Reference
Text formatting uses a bitmask:
| Format | Value | Example |
|---|---|---|
| Normal | 0 | Plain text |
| Bold | 1 | bold |
| Italic | 2 | italic |
| Strikethrough | 4 | |
| Underline | 8 | underline |
| Code | 16 | code |
| Bold+Italic | 3 | bold italic |
Node Type Summary
Ghost Koenig editor supports these node types (from TryGhost/Koenig):
| Category | Node Types |
|---|---|
| Text | heading, paragraph, quote, aside, ExtendedText |
| Lists | list, listitem |
| Code | codeblock |
| Media | image, gallery, video, audio, file |
| Embeds | embed, bookmark, transistor, asciinema |
| Interactive | button, toggle, callout, call-to-action, signup |
| Commerce | product, paywall |
| Layout | horizontalrule, header |
| Special | html, markdown, email, email-cta |
Files
| File | Purpose |
|---|---|
ox-ghost.el |
Emacs org-mode export backend |
org-to-lexical.sh |
Shell wrapper for batch export |
validate-lexical.js |
Validation with Ghost’s renderer |
package.json |
npm dependencies for validator |
STYLE-GUIDE.org |
Authoring best practices |
test-*.org |
Test files |
Version History
0.9.0 (2026-01-31)
- Added ASCIINEMA block for embedding terminal recordings
- Full player options: speed, theme, poster, autoplay, loop, fit, controls, markers
- Local cast files tracked in
media_filesfor upload to Ghost - Reset export state between exports for clean media tracking
0.8.0 (2026-01-31)
- Added round-trip metadata sync (
ghost-publishwrites#+GHOST_ID:etc back to org) - Added
ghost-pull-metadatato refresh org headers from Ghost - Added
ghost-statusto show sync diff ghost-updatenow auto-detects post from#+GHOST_ID:header- Fixed link display text showing
//instead of full URL
0.7.0 (2026-01-30)
- Added
ox-ghost-publish.elfor complete publishing workflow - Added media generation functions (TTS, images, video)
- Added media enrichment (auto-fetch dimensions, duration)
0.6.0 (2026-01-29)
- Added bundled Ghost renderer for local validation
- Added
validate-lexical.jstool - Added
STYLE-GUIDE.orgauthoring guide - Renamed module to
ox-ghost
0.5.0 (2026-01-29)
- Added REPL block for code + output with 5 styles (simple, labeled, callout, toggle, aside)
- Added fixed-width element support (: prefix results)
- Fixed post-blank spacing between formatted elements
- Fixed nil return values causing "wrong-type-argument stringp" errors
0.4.0 (2026-01-28)
- Added support for all Ghost card types
- Fixed JSON null/false encoding
- Improved parameter parsing for URLs and multi-word values
- Matched Ghost node property names exactly
0.3.0 (2026-01-28)
- Marker-based JSON assembly approach
- Basic node support
Ghost Publishing Workflow
For a complete publishing workflow with media generation and enrichment, see ox-ghost-publish.el.
Quick Start
(require 'ox-ghost-publish)
Phases
| Phase | Command | Purpose |
|---|---|---|
| Generate | ghost-tts, ghost-image, ghost-video |
Create media, upload to Ghost |
| Enrich | M-x ghost-enrich-buffer |
Add metadata to media blocks |
| Preview | M-x ghost-preview |
View HTML locally |
| Publish | M-x ghost-publish |
Send to Ghost as draft |
Round-Trip Metadata Sync
After publishing, ox-ghost automatically syncs Ghost metadata back to your org file:
#+GHOST_ID: 697dc6b53c8ddf0001728f9f
#+GHOST_UUID: e34c9f93-6f87-4cad-ae5c-c4e338d1df14
#+GHOST_SLUG: my-post-slug
#+GHOST_URL: https://www.ii.coop/my-post-slug/
#+GHOST_STATUS: draft
#+GHOST_CREATED_AT: 2026-01-31T09:09:09.000Z
#+GHOST_UPDATED_AT: 2026-01-31T09:09:09.000Z
This enables a true round-trip workflow:
| Command | Behavior |
|---|---|
M-x ghost-publish |
Create post, sync id/uuid/url back to org |
M-x ghost-update |
Auto-detects post from #+GHOST_ID:, updates |
M-x ghost-pull-metadata |
Refresh org headers from Ghost |
M-x ghost-status |
Show diff between local org and Ghost |
The org file becomes your source of truth while staying in sync with Ghost.
Member discussion