6 min read

ox-ghost: Org-mode to Ghost Lexical JSON Exporter

Export org-mode documents directly to Ghost CMS with full support for all Koenig editor card types.
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 buffer
  • M-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
#+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:

  1. Add it to the media_fileslist in metadata
  2. Transform the path to /content/media/casts/demo.cast
  3. 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 strikethrough
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.

Resources