ghost-blocks
v0.3.1
Published
Publish to Ghost CMS using a simple, flat content blocks format. Wraps the Ghost Admin API and builds Lexical documents internally.
Maintainers
Readme
ghost-blocks
Node.js library for publishing to Ghost CMS using a simple, flat content blocks format. Internally builds Lexical documents and handles JWT authentication.
Install
npm install ghost-blocksRequires Node.js 18+.
Using with AI agents
If you're generating blog content with an AI agent (Claude, GPT, etc.) and piping it into ghost-blocks, two ready-to-use resources ship with the package:
schema/ai-prompt.md — A copy-paste system prompt template
that teaches an AI agent how to produce valid content blocks. Drop it into your
agent's system message.
schema/blocks.schema.json — Formal JSON Schema (Draft-7)
for strict structured-output validation. Use with OpenAI's response_format
or Anthropic's tool_use input_schema.
import { getAiPromptTemplate, getContentBlocksJsonSchema } from 'ghost-blocks';
const systemPrompt = `You are a blog writer. ${getAiPromptTemplate()}`;
const jsonSchema = getContentBlocksJsonSchema();
// Pass `jsonSchema` to OpenAI structured outputs:
// response_format: { type: 'json_schema', json_schema: { name: 'blogPost', schema: jsonSchema, strict: true } }
// or to Anthropic tool_use:
// tools: [{ name: 'submit_blog_post', input_schema: jsonSchema }]Authentication
Create a custom integration in Ghost Admin:
- Settings → Integrations → Add custom integration
- Copy the Admin API Key (format:
{id}:{hex_secret})
The library uses this key to generate short-lived JWT tokens internally on every request.
Usage
Basic post
import { GhostPublisher } from 'ghost-blocks';
const publisher = new GhostPublisher({
url: 'https://my-site.ghost.io',
adminKey: process.env.GHOST_ADMIN_KEY!,
});
const post = await publisher.createPost({
title: 'Hello, world',
content: [
{ type: 'paragraph', text: 'My first post.' },
],
status: 'published',
});
console.log(post.url);Rich content
await publisher.createPost({
title: 'Weekly Market Update',
status: 'draft',
excerpt: 'This week in finance.',
tags: ['Markets', 'Weekly', '#internal-research'],
feature_image: 'https://example.com/hero.jpg',
content: [
{ type: 'paragraph', text: 'Opening with **bold** and *italic*.' },
{ type: 'heading', level: 2, text: 'Key Takeaways' },
{ type: 'callout', text: 'S&P 500 up 2.3% this week.', emoji: '📈', color: 'green' },
{ type: 'image', src: 'https://example.com/chart.png', alt: 'Chart' },
{ type: 'divider' },
{ type: 'button', text: 'Read More', url: 'https://example.com', alignment: 'center' },
{ type: 'paywall' },
{ type: 'paragraph', text: 'Premium content here.' },
],
seo: {
meta_title: 'Weekly Market Update',
meta_description: 'This week in finance — concise analysis under 160 chars.',
},
newsletter: {
send: true,
slug: 'weekly-newsletter',
segment: 'status:free',
},
});Update a post
updatePost automatically fetches the current updated_at for collision detection — you don't need to manage it yourself.
await publisher.updatePost(postId, {
title: 'Updated title',
content: [{ type: 'paragraph', text: 'Replaced.' }],
});Note: Tag and author arrays are replaced, not merged. Always send the full desired state.
List posts
const { posts, meta } = await publisher.browsePosts({
status: 'published',
tag: 'finance',
limit: 10,
order: 'published_at desc',
});Other operations
const tags = await publisher.listTags();
const newsletters = await publisher.listNewsletters();
await publisher.deletePost(postId);
// Upload an image and use the returned URL in a content block
const url = await publisher.uploadImageFromPath('./hero.jpg');Content Block Reference
Every block has a type field. All other fields are documented below.
Text blocks
{ type: 'paragraph', text: string }
{ type: 'heading', text: string, level?: 1 | 2 | 3 | 4 | 5 | 6 } // default level: 2
{ type: 'quote', text: string }The text field supports inline markdown:
**bold**→ bold*italic*→ italic***bold italic***→ bold italic`code`→code[link text](url)→ clickable link
Media blocks
{ type: 'image', src: string, alt?: string, caption?: string, width?: number | 'wide' | 'full' }
{ type: 'gallery', images: Array<{ src, alt?, caption?, width?, height? }> }
{ type: 'video', src: string, caption?: string, thumbnail?: string }
{ type: 'audio', src: string, title?: string, duration?: number }
{ type: 'file', src: string, title?: string, filename?: string, caption?: string }
{ type: 'embed', url: string } // YouTube, Vimeo, Twitter, Spotify, SoundCloud, CodePen — auto-detects via oEmbedLayout blocks
{ type: 'divider' }
{ type: 'paywall' }
{ type: 'header', heading: string, subheading?: string, background_image?: string, button_text?: string, button_url?: string }
{ type: 'toggle', heading: string, content: string }Interactive blocks
{ type: 'button', text: string, url: string, alignment?: 'left' | 'center' | 'right' }
{ type: 'callout', text: string, emoji?: string, color?: string }
{ type: 'bookmark', url: string, /* metadata auto-fetched from OpenGraph */ }
{ type: 'signup', heading?, subheading?, button_text?, button_color?, background_color?, ... }
{ type: 'call_to_action', text?, button_text?, button_url? }
{ type: 'product', title: string, description?, image?, button_text?, button_url?, rating? }Code/raw
{ type: 'codeblock', code: string, language?: string, caption?: string }
{ type: 'html', html: string }
{ type: 'markdown', markdown: string }Email-only blocks
{ type: 'email_content', html: string } // Visible in email only, not on web
{ type: 'email_cta', text?: string, button_text?: string, button_url?: string, segment?: string }Advanced
Skip enrichment for offline/test scenarios
By default, bookmark blocks fetch OpenGraph metadata and embed blocks fetch oEmbed data. Disable for tests:
import { GhostPublisher, LexicalBuilder } from 'ghost-blocks';
const publisher = new GhostPublisher({ url, adminKey });
publisher.builder = new LexicalBuilder({ skipEnrichment: true });Build Lexical without publishing
const lexical = await publisher.buildLexical([
{ type: 'paragraph', text: 'preview' },
]);
// lexical is a JSON string ready for Ghost's `lexical` fieldDirect API access
For advanced use cases, the underlying GhostClient is exposed:
publisher.client.createPost({ title: 'Raw', lexical: '...' });
publisher.client.uploadImageBuffer(buffer, 'photo.jpg', 'image/jpeg');Security
This library was built with security in mind:
Timing-safe authentication — JWT secrets compared with
crypto.timingSafeEqualSSRF protection —
bookmarkandembedURLs are validated against private IP ranges and Docker hostnames before any HTTP request. Customize viaUrlValidator:import { UrlValidator, OpenGraphFetcher, LexicalBuilder } from 'ghost-blocks'; const validator = new UrlValidator({ extraBlockedHostnames: ['my-internal-service'], }); const opengraph = new OpenGraphFetcher({ validator }); const builder = new LexicalBuilder({ opengraph });No code injection fields —
codeinjection_head/codeinjection_footare intentionally not exposed (these inject arbitrary JS into every page).
TypeScript
Full TypeScript types ship with the package. The main types you'll use:
import type {
GhostPublisher,
ContentBlock,
CreatePostInput,
UpdatePostInput,
PostSummary,
PostStatus,
PostVisibility,
Tag,
Newsletter,
} from 'ghost-blocks';More from the author
If you publish to Ghost from n8n workflows, you might also like:
- n8n-nodes-ghost-blocks — The official n8n community node wrapping this library. Drag-and-drop publishing in n8n.
- Nodey (launching soon) — A mobile command centre for n8n. Run and debug workflows from your phone, with an AI workflow builder, geo-fenced location-based triggers, and NFC triggers.
- n8n workflow templates — A growing collection of production-ready n8n workflow templates.
License
MIT
