content-pipeline/
├── .github/
│ └── workflows/
│ └── publish.yml
├── posts/
│ ├── my-first-post.md
│ └── another-post.md
├── scripts/
│ ├── publish.js
│ ├── publishers/
│ │ ├── wordpress.js
│ │ ├── ghost.js
│ │ └── webflow.js
│ └── utils/
│ ├── parse-frontmatter.js
│ └── transform-markdown.js
├── package.json
└── .env.example
content-pipeline/
├── .github/
│ └── workflows/
│ └── publish.yml
├── posts/
│ ├── my-first-post.md
│ └── another-post.md
├── scripts/
│ ├── publish.js
│ ├── publishers/
│ │ ├── wordpress.js
│ │ ├── ghost.js
│ │ └── webflow.js
│ └── utils/
│ ├── parse-frontmatter.js
│ └── transform-markdown.js
├── package.json
└── .env.example
content-pipeline/
├── .github/
│ └── workflows/
│ └── publish.yml
├── posts/
│ ├── my-first-post.md
│ └── another-post.md
├── scripts/
│ ├── publish.js
│ ├── publishers/
│ │ ├── wordpress.js
│ │ ├── ghost.js
│ │ └── webflow.js
│ └── utils/
│ ├── parse-frontmatter.js
│ └── transform-markdown.js
├── package.json
└── .env.example
---
title: "My Article Title"
slug: "my-article-title"
description: "One-sentence summary for meta descriptions and CMS excerpts."
tags: ["javascript", "devops", "tutorial"]
status: "published" # or "draft"
targets: ["wordpress", "ghost"] # which CMSes to publish to
date: "2025-01-15"
cover_image: "https://images.unsplash.com/photo-xyz"
--- Your article content starts here...
---
title: "My Article Title"
slug: "my-article-title"
description: "One-sentence summary for meta descriptions and CMS excerpts."
tags: ["javascript", "devops", "tutorial"]
status: "published" # or "draft"
targets: ["wordpress", "ghost"] # which CMSes to publish to
date: "2025-01-15"
cover_image: "https://images.unsplash.com/photo-xyz"
--- Your article content starts here...
---
title: "My Article Title"
slug: "my-article-title"
description: "One-sentence summary for meta descriptions and CMS excerpts."
tags: ["javascript", "devops", "tutorial"]
status: "published" # or "draft"
targets: ["wordpress", "ghost"] # which CMSes to publish to
date: "2025-01-15"
cover_image: "https://images.unsplash.com/photo-xyz"
--- Your article content starts here...
// scripts/utils/parse-frontmatter.js
import fs from 'fs';
import matter from 'gray-matter';
import { marked } from 'marked'; export function parsePost(filePath) { const raw = fs.readFileSync(filePath, 'utf-8'); const { data: frontmatter, content } = matter(raw); // Validate required fields before we touch any API const required = ['title', 'slug', 'targets']; const missing = required.filter(field => !frontmatter[field]); if (missing.length > 0) { throw new Error(`Missing required frontmatter fields: ${missing.join(', ')} in ${filePath}`); } return { frontmatter, markdown: content, html: marked(content), // WordPress needs this filePath, };
}
// scripts/utils/parse-frontmatter.js
import fs from 'fs';
import matter from 'gray-matter';
import { marked } from 'marked'; export function parsePost(filePath) { const raw = fs.readFileSync(filePath, 'utf-8'); const { data: frontmatter, content } = matter(raw); // Validate required fields before we touch any API const required = ['title', 'slug', 'targets']; const missing = required.filter(field => !frontmatter[field]); if (missing.length > 0) { throw new Error(`Missing required frontmatter fields: ${missing.join(', ')} in ${filePath}`); } return { frontmatter, markdown: content, html: marked(content), // WordPress needs this filePath, };
}
// scripts/utils/parse-frontmatter.js
import fs from 'fs';
import matter from 'gray-matter';
import { marked } from 'marked'; export function parsePost(filePath) { const raw = fs.readFileSync(filePath, 'utf-8'); const { data: frontmatter, content } = matter(raw); // Validate required fields before we touch any API const required = ['title', 'slug', 'targets']; const missing = required.filter(field => !frontmatter[field]); if (missing.length > 0) { throw new Error(`Missing required frontmatter fields: ${missing.join(', ')} in ${filePath}`); } return { frontmatter, markdown: content, html: marked(content), // WordPress needs this filePath, };
}
npm install gray-matter marked
npm install gray-matter marked
npm install gray-matter marked
// scripts/publishers/wordpress.js
const WP_BASE = process.env.WP_BASE_URL; // e.g. https://yourblog.com
const WP_USER = process.env.WP_USERNAME;
const WP_PASS = process.env.WP_APP_PASSWORD; // Application Password, not your login password const authHeader = 'Basic ' + Buffer.from(`${WP_USER}:${WP_PASS}`).toString('base64'); async function resolveTagIds(tagNames) { const ids = []; for (const name of tagNames) { // Check if tag exists const searchRes = await fetch( `${WP_BASE}/wp-json/wp/v2/tags?search=${encodeURIComponent(name)}`, { headers: { Authorization: authHeader } } ); const existing = await searchRes.json(); if (existing.length > 0) { ids.push(existing[0].id); } else { // Create it const createRes = await fetch(`${WP_BASE}/wp-json/wp/v2/tags`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json', }, body: JSON.stringify({ name }), }); const newTag = await createRes.json(); ids.push(newTag.id); } } return ids;
} export async function publishToWordPress({ frontmatter, html }) { const tagIds = await resolveTagIds(frontmatter.tags ?? []); // Check if post already exists (idempotency) const slugCheckRes = await fetch( `${WP_BASE}/wp-json/wp/v2/posts?slug=${frontmatter.slug}`, { headers: { Authorization: authHeader } } ); const existing = await slugCheckRes.json(); const payload = { title: frontmatter.title, slug: frontmatter.slug, content: html, excerpt: frontmatter.description ?? '', status: frontmatter.status ?? 'draft', tags: tagIds, date: frontmatter.date, featured_media: 0, // Extend this if you want cover image upload }; if (existing.length > 0) { // Update const res = await fetch(`${WP_BASE}/wp-json/wp/v2/posts/${existing[0].id}`, { method: 'PUT', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const updated = await res.json(); console.log(`✅ WordPress: Updated "${updated.title.rendered}" → ${updated.link}`); return updated; } else { // Create const res = await fetch(`${WP_BASE}/wp-json/wp/v2/posts`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const created = await res.json(); console.log(`✅ WordPress: Created "${created.title.rendered}" → ${created.link}`); return created; }
}
// scripts/publishers/wordpress.js
const WP_BASE = process.env.WP_BASE_URL; // e.g. https://yourblog.com
const WP_USER = process.env.WP_USERNAME;
const WP_PASS = process.env.WP_APP_PASSWORD; // Application Password, not your login password const authHeader = 'Basic ' + Buffer.from(`${WP_USER}:${WP_PASS}`).toString('base64'); async function resolveTagIds(tagNames) { const ids = []; for (const name of tagNames) { // Check if tag exists const searchRes = await fetch( `${WP_BASE}/wp-json/wp/v2/tags?search=${encodeURIComponent(name)}`, { headers: { Authorization: authHeader } } ); const existing = await searchRes.json(); if (existing.length > 0) { ids.push(existing[0].id); } else { // Create it const createRes = await fetch(`${WP_BASE}/wp-json/wp/v2/tags`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json', }, body: JSON.stringify({ name }), }); const newTag = await createRes.json(); ids.push(newTag.id); } } return ids;
} export async function publishToWordPress({ frontmatter, html }) { const tagIds = await resolveTagIds(frontmatter.tags ?? []); // Check if post already exists (idempotency) const slugCheckRes = await fetch( `${WP_BASE}/wp-json/wp/v2/posts?slug=${frontmatter.slug}`, { headers: { Authorization: authHeader } } ); const existing = await slugCheckRes.json(); const payload = { title: frontmatter.title, slug: frontmatter.slug, content: html, excerpt: frontmatter.description ?? '', status: frontmatter.status ?? 'draft', tags: tagIds, date: frontmatter.date, featured_media: 0, // Extend this if you want cover image upload }; if (existing.length > 0) { // Update const res = await fetch(`${WP_BASE}/wp-json/wp/v2/posts/${existing[0].id}`, { method: 'PUT', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const updated = await res.json(); console.log(`✅ WordPress: Updated "${updated.title.rendered}" → ${updated.link}`); return updated; } else { // Create const res = await fetch(`${WP_BASE}/wp-json/wp/v2/posts`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const created = await res.json(); console.log(`✅ WordPress: Created "${created.title.rendered}" → ${created.link}`); return created; }
}
// scripts/publishers/wordpress.js
const WP_BASE = process.env.WP_BASE_URL; // e.g. https://yourblog.com
const WP_USER = process.env.WP_USERNAME;
const WP_PASS = process.env.WP_APP_PASSWORD; // Application Password, not your login password const authHeader = 'Basic ' + Buffer.from(`${WP_USER}:${WP_PASS}`).toString('base64'); async function resolveTagIds(tagNames) { const ids = []; for (const name of tagNames) { // Check if tag exists const searchRes = await fetch( `${WP_BASE}/wp-json/wp/v2/tags?search=${encodeURIComponent(name)}`, { headers: { Authorization: authHeader } } ); const existing = await searchRes.json(); if (existing.length > 0) { ids.push(existing[0].id); } else { // Create it const createRes = await fetch(`${WP_BASE}/wp-json/wp/v2/tags`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json', }, body: JSON.stringify({ name }), }); const newTag = await createRes.json(); ids.push(newTag.id); } } return ids;
} export async function publishToWordPress({ frontmatter, html }) { const tagIds = await resolveTagIds(frontmatter.tags ?? []); // Check if post already exists (idempotency) const slugCheckRes = await fetch( `${WP_BASE}/wp-json/wp/v2/posts?slug=${frontmatter.slug}`, { headers: { Authorization: authHeader } } ); const existing = await slugCheckRes.json(); const payload = { title: frontmatter.title, slug: frontmatter.slug, content: html, excerpt: frontmatter.description ?? '', status: frontmatter.status ?? 'draft', tags: tagIds, date: frontmatter.date, featured_media: 0, // Extend this if you want cover image upload }; if (existing.length > 0) { // Update const res = await fetch(`${WP_BASE}/wp-json/wp/v2/posts/${existing[0].id}`, { method: 'PUT', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const updated = await res.json(); console.log(`✅ WordPress: Updated "${updated.title.rendered}" → ${updated.link}`); return updated; } else { // Create const res = await fetch(`${WP_BASE}/wp-json/wp/v2/posts`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const created = await res.json(); console.log(`✅ WordPress: Created "${created.title.rendered}" → ${created.link}`); return created; }
}
npm install @tryghost/admin-api
npm install @tryghost/admin-api
npm install @tryghost/admin-api
// scripts/publishers/ghost.js
import GhostAdminAPI from '@tryghost/admin-api'; const ghost = new GhostAdminAPI({ url: process.env.GHOST_URL, // e.g. https://yoursite.ghost.io key: process.env.GHOST_ADMIN_KEY, // Format: id:secret (from Ghost Admin → Integrations) version: 'v5.0',
}); export async function publishToGhost({ frontmatter, markdown }) { // Ghost tags are objects, not just strings const tags = (frontmatter.tags ?? []).map(name => ({ name })); const postData = { title: frontmatter.title, slug: frontmatter.slug, mobiledoc: buildMobiledoc(markdown), custom_excerpt: frontmatter.description ?? '', status: frontmatter.status ?? 'draft', tags, published_at: frontmatter.date ? new Date(frontmatter.date).toISOString() : undefined, feature_image: frontmatter.cover_image ?? null, }; try { // Try to find existing post by slug const existing = await ghost.posts.browse({ filter: `slug:${frontmatter.slug}` }); if (existing.length > 0) { const updated = await ghost.posts.edit({ id: existing[0].id, updated_at: existing[0].updated_at, // Required for conflict detection ...postData, }); console.log(`✅ Ghost: Updated "${updated.title}" → ${updated.url}`); return updated; } else { const created = await ghost.posts.add(postData); console.log(`✅ Ghost: Created "${created.title}" → ${created.url}`); return created; } } catch (err) { throw new Error(`Ghost publish failed: ${err.message}`); }
} // Ghost uses Mobiledoc internally. This wraps raw Markdown in a Markdown card.
function buildMobiledoc(markdown) { return JSON.stringify({ version: '0.3.1', markups: [], atoms: [], cards: [['markdown', { markdown }]], sections: [[10, 0]], });
}
// scripts/publishers/ghost.js
import GhostAdminAPI from '@tryghost/admin-api'; const ghost = new GhostAdminAPI({ url: process.env.GHOST_URL, // e.g. https://yoursite.ghost.io key: process.env.GHOST_ADMIN_KEY, // Format: id:secret (from Ghost Admin → Integrations) version: 'v5.0',
}); export async function publishToGhost({ frontmatter, markdown }) { // Ghost tags are objects, not just strings const tags = (frontmatter.tags ?? []).map(name => ({ name })); const postData = { title: frontmatter.title, slug: frontmatter.slug, mobiledoc: buildMobiledoc(markdown), custom_excerpt: frontmatter.description ?? '', status: frontmatter.status ?? 'draft', tags, published_at: frontmatter.date ? new Date(frontmatter.date).toISOString() : undefined, feature_image: frontmatter.cover_image ?? null, }; try { // Try to find existing post by slug const existing = await ghost.posts.browse({ filter: `slug:${frontmatter.slug}` }); if (existing.length > 0) { const updated = await ghost.posts.edit({ id: existing[0].id, updated_at: existing[0].updated_at, // Required for conflict detection ...postData, }); console.log(`✅ Ghost: Updated "${updated.title}" → ${updated.url}`); return updated; } else { const created = await ghost.posts.add(postData); console.log(`✅ Ghost: Created "${created.title}" → ${created.url}`); return created; } } catch (err) { throw new Error(`Ghost publish failed: ${err.message}`); }
} // Ghost uses Mobiledoc internally. This wraps raw Markdown in a Markdown card.
function buildMobiledoc(markdown) { return JSON.stringify({ version: '0.3.1', markups: [], atoms: [], cards: [['markdown', { markdown }]], sections: [[10, 0]], });
}
// scripts/publishers/ghost.js
import GhostAdminAPI from '@tryghost/admin-api'; const ghost = new GhostAdminAPI({ url: process.env.GHOST_URL, // e.g. https://yoursite.ghost.io key: process.env.GHOST_ADMIN_KEY, // Format: id:secret (from Ghost Admin → Integrations) version: 'v5.0',
}); export async function publishToGhost({ frontmatter, markdown }) { // Ghost tags are objects, not just strings const tags = (frontmatter.tags ?? []).map(name => ({ name })); const postData = { title: frontmatter.title, slug: frontmatter.slug, mobiledoc: buildMobiledoc(markdown), custom_excerpt: frontmatter.description ?? '', status: frontmatter.status ?? 'draft', tags, published_at: frontmatter.date ? new Date(frontmatter.date).toISOString() : undefined, feature_image: frontmatter.cover_image ?? null, }; try { // Try to find existing post by slug const existing = await ghost.posts.browse({ filter: `slug:${frontmatter.slug}` }); if (existing.length > 0) { const updated = await ghost.posts.edit({ id: existing[0].id, updated_at: existing[0].updated_at, // Required for conflict detection ...postData, }); console.log(`✅ Ghost: Updated "${updated.title}" → ${updated.url}`); return updated; } else { const created = await ghost.posts.add(postData); console.log(`✅ Ghost: Created "${created.title}" → ${created.url}`); return created; } } catch (err) { throw new Error(`Ghost publish failed: ${err.message}`); }
} // Ghost uses Mobiledoc internally. This wraps raw Markdown in a Markdown card.
function buildMobiledoc(markdown) { return JSON.stringify({ version: '0.3.1', markups: [], atoms: [], cards: [['markdown', { markdown }]], sections: [[10, 0]], });
}
// scripts/publishers/webflow.js
const WF_TOKEN = process.env.WEBFLOW_API_TOKEN;
const WF_COLLECTION_ID = process.env.WEBFLOW_COLLECTION_ID; const headers = { Authorization: `Bearer ${WF_TOKEN}`, 'Content-Type': 'application/json', 'accept-version': '1.0.0',
}; export async function publishToWebflow({ frontmatter, html }) { // Check for existing item by slug const listRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items`, { headers } ); const { items } = await listRes.json(); const existing = items?.find(item => item['slug'] === frontmatter.slug); // Map your frontmatter to Webflow field slugs // These must match the field slugs in your Webflow CMS collection const fields = { name: frontmatter.title, slug: frontmatter.slug, 'post-body': html, // Your rich-text field slug in Webflow 'meta-description': frontmatter.description ?? '', 'tags-string': (frontmatter.tags ?? []).join(', '), // Webflow doesn't have native tags _archived: false, _draft: frontmatter.status !== 'published', }; if (existing) { const updateRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items/${existing._id}`, { method: 'PUT', headers, body: JSON.stringify({ fields }) } ); const updated = await updateRes.json(); console.log(`✅ Webflow: Updated "${updated.fields.name}"`); // Publish immediately if status is "published" if (frontmatter.status === 'published') { await publishWebflowItem(updated._id); } return updated; } else { const createRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items`, { method: 'POST', headers, body: JSON.stringify({ fields }) } ); const created = await createRes.json(); console.log(`✅ Webflow: Created "${created.fields.name}"`); if (frontmatter.status === 'published') { await publishWebflowItem(created._id); } return created; }
} // Webflow requires a separate "publish" API call after creating/updating
async function publishWebflowItem(itemId) { await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items/publish`, { method: 'PUT', headers, body: JSON.stringify({ itemIds: [itemId] }), } );
}
// scripts/publishers/webflow.js
const WF_TOKEN = process.env.WEBFLOW_API_TOKEN;
const WF_COLLECTION_ID = process.env.WEBFLOW_COLLECTION_ID; const headers = { Authorization: `Bearer ${WF_TOKEN}`, 'Content-Type': 'application/json', 'accept-version': '1.0.0',
}; export async function publishToWebflow({ frontmatter, html }) { // Check for existing item by slug const listRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items`, { headers } ); const { items } = await listRes.json(); const existing = items?.find(item => item['slug'] === frontmatter.slug); // Map your frontmatter to Webflow field slugs // These must match the field slugs in your Webflow CMS collection const fields = { name: frontmatter.title, slug: frontmatter.slug, 'post-body': html, // Your rich-text field slug in Webflow 'meta-description': frontmatter.description ?? '', 'tags-string': (frontmatter.tags ?? []).join(', '), // Webflow doesn't have native tags _archived: false, _draft: frontmatter.status !== 'published', }; if (existing) { const updateRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items/${existing._id}`, { method: 'PUT', headers, body: JSON.stringify({ fields }) } ); const updated = await updateRes.json(); console.log(`✅ Webflow: Updated "${updated.fields.name}"`); // Publish immediately if status is "published" if (frontmatter.status === 'published') { await publishWebflowItem(updated._id); } return updated; } else { const createRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items`, { method: 'POST', headers, body: JSON.stringify({ fields }) } ); const created = await createRes.json(); console.log(`✅ Webflow: Created "${created.fields.name}"`); if (frontmatter.status === 'published') { await publishWebflowItem(created._id); } return created; }
} // Webflow requires a separate "publish" API call after creating/updating
async function publishWebflowItem(itemId) { await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items/publish`, { method: 'PUT', headers, body: JSON.stringify({ itemIds: [itemId] }), } );
}
// scripts/publishers/webflow.js
const WF_TOKEN = process.env.WEBFLOW_API_TOKEN;
const WF_COLLECTION_ID = process.env.WEBFLOW_COLLECTION_ID; const headers = { Authorization: `Bearer ${WF_TOKEN}`, 'Content-Type': 'application/json', 'accept-version': '1.0.0',
}; export async function publishToWebflow({ frontmatter, html }) { // Check for existing item by slug const listRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items`, { headers } ); const { items } = await listRes.json(); const existing = items?.find(item => item['slug'] === frontmatter.slug); // Map your frontmatter to Webflow field slugs // These must match the field slugs in your Webflow CMS collection const fields = { name: frontmatter.title, slug: frontmatter.slug, 'post-body': html, // Your rich-text field slug in Webflow 'meta-description': frontmatter.description ?? '', 'tags-string': (frontmatter.tags ?? []).join(', '), // Webflow doesn't have native tags _archived: false, _draft: frontmatter.status !== 'published', }; if (existing) { const updateRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items/${existing._id}`, { method: 'PUT', headers, body: JSON.stringify({ fields }) } ); const updated = await updateRes.json(); console.log(`✅ Webflow: Updated "${updated.fields.name}"`); // Publish immediately if status is "published" if (frontmatter.status === 'published') { await publishWebflowItem(updated._id); } return updated; } else { const createRes = await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items`, { method: 'POST', headers, body: JSON.stringify({ fields }) } ); const created = await createRes.json(); console.log(`✅ Webflow: Created "${created.fields.name}"`); if (frontmatter.status === 'published') { await publishWebflowItem(created._id); } return created; }
} // Webflow requires a separate "publish" API call after creating/updating
async function publishWebflowItem(itemId) { await fetch( `https://api.webflow.com/collections/${WF_COLLECTION_ID}/items/publish`, { method: 'PUT', headers, body: JSON.stringify({ itemIds: [itemId] }), } );
}
// scripts/publish.js
import { parsePost } from './utils/parse-frontmatter.js';
import { publishToWordPress } from './publishers/wordpress.js';
import { publishToGhost } from './publishers/ghost.js';
import { publishToWebflow } from './publishers/webflow.js';
import { execSync } from 'child_process';
import path from 'path'; const PUBLISHERS = { wordpress: publishToWordPress, ghost: publishToGhost, webflow: publishToWebflow,
}; async function main() { // Get list of changed .md files from git // In GitHub Actions, GITHUB_SHA gives us the current commit const changedFiles = execSync( 'git diff --name-only HEAD~1 HEAD -- "posts/*.md"' ) .toString() .trim() .split('\n') .filter(Boolean); if (changedFiles.length === 0) { console.log('No markdown files changed. Nothing to publish.'); return; } console.log(`Found ${changedFiles.length} changed post(s): ${changedFiles.join(', ')}`); const results = { success: [], failed: [] }; for (const filePath of changedFiles) { const fullPath = path.resolve(filePath); try { const post = parsePost(fullPath); const { targets = [] } = post.frontmatter; if (targets.length === 0) { console.log(`⚠️ Skipping ${filePath}: no targets specified in frontmatter`); continue; } for (const target of targets) { const publisher = PUBLISHERS[target]; if (!publisher) { console.warn(`⚠️ Unknown target "${target}" in ${filePath}`); continue; } await publisher(post); } results.success.push(filePath); } catch (err) { console.error(`❌ Failed to publish ${filePath}: ${err.message}`); results.failed.push({ filePath, error: err.message }); } } console.log(`\nDone. ${results.success.length} succeeded, ${results.failed.length} failed.`); // Exit with error if any publish failed — this fails the GitHub Action if (results.failed.length > 0) { process.exit(1); }
} main();
// scripts/publish.js
import { parsePost } from './utils/parse-frontmatter.js';
import { publishToWordPress } from './publishers/wordpress.js';
import { publishToGhost } from './publishers/ghost.js';
import { publishToWebflow } from './publishers/webflow.js';
import { execSync } from 'child_process';
import path from 'path'; const PUBLISHERS = { wordpress: publishToWordPress, ghost: publishToGhost, webflow: publishToWebflow,
}; async function main() { // Get list of changed .md files from git // In GitHub Actions, GITHUB_SHA gives us the current commit const changedFiles = execSync( 'git diff --name-only HEAD~1 HEAD -- "posts/*.md"' ) .toString() .trim() .split('\n') .filter(Boolean); if (changedFiles.length === 0) { console.log('No markdown files changed. Nothing to publish.'); return; } console.log(`Found ${changedFiles.length} changed post(s): ${changedFiles.join(', ')}`); const results = { success: [], failed: [] }; for (const filePath of changedFiles) { const fullPath = path.resolve(filePath); try { const post = parsePost(fullPath); const { targets = [] } = post.frontmatter; if (targets.length === 0) { console.log(`⚠️ Skipping ${filePath}: no targets specified in frontmatter`); continue; } for (const target of targets) { const publisher = PUBLISHERS[target]; if (!publisher) { console.warn(`⚠️ Unknown target "${target}" in ${filePath}`); continue; } await publisher(post); } results.success.push(filePath); } catch (err) { console.error(`❌ Failed to publish ${filePath}: ${err.message}`); results.failed.push({ filePath, error: err.message }); } } console.log(`\nDone. ${results.success.length} succeeded, ${results.failed.length} failed.`); // Exit with error if any publish failed — this fails the GitHub Action if (results.failed.length > 0) { process.exit(1); }
} main();
// scripts/publish.js
import { parsePost } from './utils/parse-frontmatter.js';
import { publishToWordPress } from './publishers/wordpress.js';
import { publishToGhost } from './publishers/ghost.js';
import { publishToWebflow } from './publishers/webflow.js';
import { execSync } from 'child_process';
import path from 'path'; const PUBLISHERS = { wordpress: publishToWordPress, ghost: publishToGhost, webflow: publishToWebflow,
}; async function main() { // Get list of changed .md files from git // In GitHub Actions, GITHUB_SHA gives us the current commit const changedFiles = execSync( 'git diff --name-only HEAD~1 HEAD -- "posts/*.md"' ) .toString() .trim() .split('\n') .filter(Boolean); if (changedFiles.length === 0) { console.log('No markdown files changed. Nothing to publish.'); return; } console.log(`Found ${changedFiles.length} changed post(s): ${changedFiles.join(', ')}`); const results = { success: [], failed: [] }; for (const filePath of changedFiles) { const fullPath = path.resolve(filePath); try { const post = parsePost(fullPath); const { targets = [] } = post.frontmatter; if (targets.length === 0) { console.log(`⚠️ Skipping ${filePath}: no targets specified in frontmatter`); continue; } for (const target of targets) { const publisher = PUBLISHERS[target]; if (!publisher) { console.warn(`⚠️ Unknown target "${target}" in ${filePath}`); continue; } await publisher(post); } results.success.push(filePath); } catch (err) { console.error(`❌ Failed to publish ${filePath}: ${err.message}`); results.failed.push({ filePath, error: err.message }); } } console.log(`\nDone. ${results.success.length} succeeded, ${results.failed.length} failed.`); // Exit with error if any publish failed — this fails the GitHub Action if (results.failed.length > 0) { process.exit(1); }
} main();
# .github/workflows/publish.yml
name: Publish Content on: push: branches: - main # Publishes live - staging # Publishes as drafts (see note below) paths: - 'posts/**' # Only triggers when posts change — not on script edits jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 2 # Need at least 2 commits for git diff to work - name: Setup Node uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - name: Run publish script env: # WordPress WP_BASE_URL: ${{ secrets.WP_BASE_URL }} WP_USERNAME: ${{ secrets.WP_USERNAME }} WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }} # Ghost GHOST_URL: ${{ secrets.GHOST_URL }} GHOST_ADMIN_KEY: ${{ secrets.GHOST_ADMIN_KEY }} # Webflow WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }} WEBFLOW_COLLECTION_ID: ${{ secrets.WEBFLOW_COLLECTION_ID }} run: node scripts/publish.js
# .github/workflows/publish.yml
name: Publish Content on: push: branches: - main # Publishes live - staging # Publishes as drafts (see note below) paths: - 'posts/**' # Only triggers when posts change — not on script edits jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 2 # Need at least 2 commits for git diff to work - name: Setup Node uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - name: Run publish script env: # WordPress WP_BASE_URL: ${{ secrets.WP_BASE_URL }} WP_USERNAME: ${{ secrets.WP_USERNAME }} WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }} # Ghost GHOST_URL: ${{ secrets.GHOST_URL }} GHOST_ADMIN_KEY: ${{ secrets.GHOST_ADMIN_KEY }} # Webflow WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }} WEBFLOW_COLLECTION_ID: ${{ secrets.WEBFLOW_COLLECTION_ID }} run: node scripts/publish.js
# .github/workflows/publish.yml
name: Publish Content on: push: branches: - main # Publishes live - staging # Publishes as drafts (see note below) paths: - 'posts/**' # Only triggers when posts change — not on script edits jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 2 # Need at least 2 commits for git diff to work - name: Setup Node uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - name: Run publish script env: # WordPress WP_BASE_URL: ${{ secrets.WP_BASE_URL }} WP_USERNAME: ${{ secrets.WP_USERNAME }} WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }} # Ghost GHOST_URL: ${{ secrets.GHOST_URL }} GHOST_ADMIN_KEY: ${{ secrets.GHOST_ADMIN_KEY }} # Webflow WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }} WEBFLOW_COLLECTION_ID: ${{ secrets.WEBFLOW_COLLECTION_ID }} run: node scripts/publish.js
const isFirstCommit = execSync('git rev-list --count HEAD').toString().trim() === '1';
const diffCommand = isFirstCommit ? 'git diff --name-only 4b825dc HEAD -- "posts/*.md"' : 'git diff --name-only HEAD~1 HEAD -- "posts/*.md"';
const isFirstCommit = execSync('git rev-list --count HEAD').toString().trim() === '1';
const diffCommand = isFirstCommit ? 'git diff --name-only 4b825dc HEAD -- "posts/*.md"' : 'git diff --name-only HEAD~1 HEAD -- "posts/*.md"';
const isFirstCommit = execSync('git rev-list --count HEAD').toString().trim() === '1';
const diffCommand = isFirstCommit ? 'git diff --name-only 4b825dc HEAD -- "posts/*.md"' : 'git diff --name-only HEAD~1 HEAD -- "posts/*.md"';
// Add after each publish call inside the loop
await new Promise(resolve => setTimeout(resolve, 1100)); // ~55 req/min
// Add after each publish call inside the loop
await new Promise(resolve => setTimeout(resolve, 1100)); // ~55 req/min
// Add after each publish call inside the loop
await new Promise(resolve => setTimeout(resolve, 1100)); // ~55 req/min
# WordPress
WP_BASE_URL=https://yourblog.com
WP_USERNAME=your_wp_username
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx # Ghost
GHOST_URL=https://yoursite.ghost.io
GHOST_ADMIN_KEY=id:secret # Webflow
WEBFLOW_API_TOKEN=your_token
WEBFLOW_COLLECTION_ID=your_collection_id
# WordPress
WP_BASE_URL=https://yourblog.com
WP_USERNAME=your_wp_username
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx # Ghost
GHOST_URL=https://yoursite.ghost.io
GHOST_ADMIN_KEY=id:secret # Webflow
WEBFLOW_API_TOKEN=your_token
WEBFLOW_COLLECTION_ID=your_collection_id
# WordPress
WP_BASE_URL=https://yourblog.com
WP_USERNAME=your_wp_username
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx # Ghost
GHOST_URL=https://yoursite.ghost.io
GHOST_ADMIN_KEY=id:secret # Webflow
WEBFLOW_API_TOKEN=your_token
WEBFLOW_COLLECTION_ID=your_collection_id - A GitHub repo that acts as your content source of truth
- A GitHub Actions workflow that triggers on push to main
- A Node.js publish script that parses frontmatter, transforms Markdown, and hits your CMS API
- Support for WordPress (REST API), Ghost (Admin API), and Webflow (CMS API) — pick one or all three
- A staging branch that publishes drafts, a main branch that publishes live - Node.js 18+
- A GitHub repo (free tier works)
- At least one of: a WordPress site with Application Passwords enabled, a Ghost instance with Admin API access, a Webflow CMS collection
- Basic familiarity with GitHub Actions syntax