fromghost
What it does
Parses a Ghost JSON export and registers a "Ghost Export File" source in EmDash's own admin import screen, next to the built-in WordPress importers. Posts, pages, tags, and authors show up there with no separate CLI step - upload the file, review the analysis, run the import, and the rest of the pipeline (schema checks, media download, content creation) is EmDash's own.
It reads every shape Ghost's own exporter and the Admin API produce: the standard { db: [{ meta, data }] } wrapper from Ghost 2.x through 5.x, and the looser { data } or{ posts } shapes some third-party exporters use. It also handles both post-type flags Ghost has shipped: the typefield ("post" or "page") on Ghost 5, and the older booleanpage flag from Ghost 4 and earlier.
Install
npm install @plugdash/fromghostRegister
Open astro.config.mjs at the root of your Astro project. Import the plugin at the top, then add it to the pluginsarray of the emdash() integration - the one that sits inside Astro's top-level integrations array:
import { defineConfig } from "astro/config"
import emdash from "emdash/astro"
import { fromghostPlugin } from "@plugdash/fromghost"
export default defineConfig({
integrations: [
emdash({
// must be plugins, not sandboxed - fromghost needs Node fs/JSON
plugins: [
fromghostPlugin({
siteUrl: "https://yourghostsite.com",
}),
],
}),
],
})fromghost is a Native plugin. It cannot run sandboxed - parsing a site-sized JSON export needs Node, and the plugin registers itself directly into EmDash's import-source registry rather than going through a hook. Once registered, "Ghost Export File" appears as a source at /_emdash/admin/import with no further setup.
Import your Ghost site
Step 1: export your Ghost site
In Ghost admin, go to Settings -> Labs -> Export your content. This downloads a single JSON file containing your posts, pages, tags, authors, and settings - no images, since Ghost's export only ever carries image URLs, never the files themselves.
Step 2: upload and review
Open /_emdash/admin/import on your EmDash site, choose "Ghost Export File", and upload the JSON. fromghost parses it and hands back an analysis: how many posts and pages, how many public tags, how many authors and their post counts, and how many images it found feature images for. This is EmDash's standardImportAnalysis screen - the same one WordPress imports use - so there's no Ghost-specific UI to learn.
Step 3: run the import
Confirm the analysis and run the import. Whether drafts come in along with published posts is controlled by the import screen itself, not a plugin setting - fromghost reads each post's status straight from the Ghost export. From there, EmDash's own pipeline downloads images, creates the tag taxonomy, and creates the content records; fromghost's job ends at handing over normalized items.
Field mapping
| Ghost field | EmDash field |
|---|---|
title | title |
slug | slug (or regenerated - see preserveSlugs) |
html | content, via htmlToPortableText |
custom_excerpt (falls back to excerpt) | excerpt |
feature_image | featuredImage (after __GHOST_URL__ resolution) |
published_at (falls back to created_at) | date |
updated_at | modified |
meta_title | meta.seoTitle |
meta_description | meta.seoDescription |
tags via posts_tags | tags (public tags only) |
author via posts_authors | author (first author's slug) |
A few things worth knowing about that table. Body HTML goes through@plugdash/html-to-portable-text - the same shared converter @plugdash/fromsubstack uses - so Ghost's HTML becomes the Portable Text blocks EmDash stores content as, not raw HTML. Only the first author on a post comes across; Ghost's co-authors beyond the first are not imported. Ghost's internal tags (visibility: "internal") are Ghost's own bookkeeping and are never carried over, even with importTags on.
Feature and inline images are handed to EmDash's import pipeline as a URL - fromghost does not download or upload media itself. Ghost writes those URLs as __GHOST_URL__/content/images/...rather than an absolute address, so fromghost needs thesiteUrl option to resolve them. Without it, an image path stays site-relative, can't be downloaded, and is skipped with a warning rather than failing the whole post.
A Ghost post, mapped
A single post from a Ghost export (trimmed to the fields that matter):
{
"id": "1",
"title": "Hello Ghost",
"slug": "hello-ghost",
"html": "<p>First <strong>post</strong>.</p>",
"feature_image": "__GHOST_URL__/content/images/2024/01/cover.jpg",
"feature_image_alt": "A cover",
"published_at": "2024-01-15T10:00:00.000Z",
"created_at": "2024-01-14T09:00:00.000Z",
"updated_at": "2024-01-16T09:00:00.000Z",
"status": "published",
"custom_excerpt": "An intro",
"meta_title": "Hello Ghost | SEO",
"meta_description": "SEO description",
"type": "post"
}...with a public tag ("news"), an internal tag ("internal"), and one author (Ada Lovelace, slug "ada") joined in via the export'sposts_tags and posts_authors tables. WithfromghostPlugin({ siteUrl: "https://adasblog.example.com" }), the normalized item fromghost hands to EmDash looks like this (Portable Text _key fields left out for readability):
{
sourceId: "1",
postType: "post",
status: "publish",
slug: "hello-ghost",
title: "Hello Ghost",
content: [
{
_type: "block",
style: "normal",
children: [
{ _type: "span", text: "First " },
{ _type: "span", text: "post", marks: ["strong"] },
{ _type: "span", text: "." },
],
},
],
excerpt: "An intro",
date: "2024-01-15T10:00:00.000Z",
modified: "2024-01-16T09:00:00.000Z",
author: "ada",
tags: ["news"],
meta: {
seoTitle: "Hello Ghost | SEO",
seoDescription: "SEO description",
},
featuredImage: "https://adasblog.example.com/content/images/2024/01/cover.jpg",
}Note what dropped out along the way: the "internal" tag never appears in tags, and without siteUrl set,featuredImage would be undefined instead - the image stays referenced only by its unresolved Ghost path, and fromghost calls onWarn to say so.
Known limitations
- No Mobiledoc conversion. Ghost 3 and older editor content has no converter - those posts import with an empty body and a warning.
- Ghost 5 Lexical drafts that were never rendered to HTML (
html: null) are recovered as plain-text paragraphs only, with a warning - formatting, links, and embeds are lost. - No media download or upload.
featuredImageis a URL; EmDash's own import pipeline owns the actual fetch. - No import of Ghost members, newsletters, or comments.
- Ghost's internal-visibility tags are never preserved as taxonomy.
- Duplicate slugs are skipped (the first occurrence wins) with a warning, rather than overwriting or erroring.
- Import status (draft vs. published) isn't a plugin option - it comes from each post's own Ghost status, and whether drafts are included at all is controlled by EmDash's import screen.
- Cannot be sandboxed - it needs Node's
fs/JSON parsing and registers directly into EmDash's import-source registry.
Props
| prop | type | default | description |
|---|---|---|---|
targetCollection | string | "posts" | Collection suggested for Ghost posts in the import UI |
preserveSlugs | boolean | true | Keep Ghost's slugs instead of regenerating from the title |
importImages | boolean | true | Carry feature and inline images over |
importTags | boolean | true | Carry Ghost tags over as taxonomy terms |
siteUrl | string | "" | The Ghost site's URL, to resolve __GHOST_URL__ image paths |
onWarn | (message: string) => void | discard | Called for every recoverable problem during import |
For agents
After installing @plugdash/fromghost and registering it in astro.config.mjs, no further UI setup is needed - "Ghost Export File" appears automatically as a source on the admin import screen. To verify, export a Ghost site as JSON, upload it, confirm the analysis shows the expected post/page/tag counts, then run the import and check the created content.
If feature images aren't resolving, check that siteUrlis set in fromghostPlugin({ siteUrl: "..." }) - without it, Ghost's __GHOST_URL__-relative paths are skipped, not guessed at. If a post's body is empty after import, check the onWarn output: it's either an unsupported Mobiledoc post or a Lexical draft with no rendered HTML.
Metadata written: none - fromghost is an import source, not a hook, and keeps no persistent state.