@plugdash/fromghost

fromghost

950 words·5min read

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

Register

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:

astro.config.mjsjs
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 fieldEmDash field
titletitle
slugslug (or regenerated - see preserveSlugs)
htmlcontent, via htmlToPortableText
custom_excerpt (falls back to excerpt)excerpt
feature_imagefeaturedImage (after __GHOST_URL__ resolution)
published_at (falls back to created_at)date
updated_atmodified
meta_titlemeta.seoTitle
meta_descriptionmeta.seoDescription
tags via posts_tagstags (public tags only)
author via posts_authorsauthor (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):

json
{
  "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):

js
{
  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. featuredImage is 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

proptypedefaultdescription
targetCollectionstring"posts"Collection suggested for Ghost posts in the import UI
preserveSlugsbooleantrueKeep Ghost's slugs instead of regenerating from the title
importImagesbooleantrueCarry feature and inline images over
importTagsbooleantrueCarry Ghost tags over as taxonomy terms
siteUrlstring""The Ghost site's URL, to resolve __GHOST_URL__ image paths
onWarn(message: string) => voiddiscardCalled 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.

built with plugdash.dev