A single EmDash plugin distributes content to blog, newsletter, and social channels simultaneously — eliminating manual cross-posting and ensuring brand-consistent messaging across every platform your audience touches. ## The Problem Marketing teams today manage an average of six to ten content distribution channels. A blog post must be formatted for the website, summarized for a newsletter, adapted into LinkedIn and Twitter threads, posted to a Slack community, and syndicated to Medium or Dev.to. Each channel has its own formatting rules, character limits, image requirements, and audience expectations. The result is a fragmented workflow where content creators manually copy, reformat, and republish the same piece across platforms. This leads to: - **Inconsistencies** — a correction on one channel never propagates to others - **Brand drift** — messaging tone and visual identity diverge across platforms - **Bottlenecks** — every distribution step requires human intervention - **Timing failures** — content hits different channels hours or days apart - **Missed opportunities** — channels get neglected because distribution is too labor-intensive At scale, these problems compound. A team publishing three posts per week across six channels performs 18 individual distribution actions — each with its own failure points and quality gaps. ## The Solution EmDash's plugin architecture solves this with a **Unified Content Distribution Engine** — a single plugin that ingests content once and dispatches it to every configured channel through adapters. The engine transforms content per-channel, manages timing via a queue, and provides a unified dashboard for monitoring distribution status. Key design principles: | Principle | Description | |-----------|-------------| | **Write once, publish everywhere** | Content enters the engine once; channel adapters handle transformation | | **Pluggable adapters** | Each channel (blog, newsletter, social) is a separate adapter — add or remove without touching core logic | | **Queue-based dispatch** | D1-backed queue ensures delivery regardless of transient failures | | **Idempotent delivery** | Re-running a failed dispatch won't duplicate content | | **Audit trail** | Every distribution event is logged for debugging and analytics | ## Architecture The distribution engine uses three layers: ``` ┌──────────────────────────────────────────────────────┐ │ Admin Dashboard │ │ (Plugin UI in EmDash admin — manage channels, │ │ view queue, retry failures) │ └──────────────┬───────────────────────────────────────┘ │ ┌──────────────▼───────────────────────────────────────┐ │ Distribution Manager │ │ - Plugin hooks into content.published event │ │ - Creates queue entries in D1 │ │ - Determines target channels from content metadata │ └──────┬──────────────┬──────────────┬─────────────────┘ │ │ │ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────────┐ │ Blog │ │ Newsletter │ │ Social │ │ Adapter │ │ Adapter │ │ Adapter │ │ (Astro │ │ (MJML/ │ │ (LinkedIn, │ │ markdown) │ │ Markdown) │ │ Twitter, etc.) │ └─────────────┘ └────────────┘ └─────────────────┘ │ │ │ └──────────────┼──────────────┘ │ ┌─────────────────────▼──────────────────────────────┐ │ D1 Distribution Queue │ │ - distribution_queue table │ │ - KV namespace for status tracking │ │ - Retry logic with exponential backoff │ └────────────────────────────────────────────────────┘ ``` ### Core Tables The plugin creates two D1 tables: ```sql CREATE TABLE distribution_queue ( id TEXT PRIMARY KEY, content_id TEXT NOT NULL, content_type TEXT NOT NULL, channel TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', payload TEXT NOT NULL, attempts INTEGER DEFAULT 0, max_attempts INTEGER DEFAULT 3, next_retry_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), completed_at TEXT, error TEXT ); CREATE INDEX idx_queue_status ON distribution_queue(status); CREATE INDEX idx_queue_content ON distribution_queue(content_id); ``` ```sql CREATE TABLE distribution_log ( id TEXT PRIMARY KEY, queue_id TEXT NOT NULL, content_id TEXT NOT NULL, channel TEXT NOT NULL, status TEXT NOT NULL, response_code INTEGER, response_body TEXT, duration_ms INTEGER, attempted_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (queue_id) REFERENCES distribution_queue(id) ); ``` ## Implementation ### Plugin Registration The EmDash plugin registers event hooks for content lifecycle: ```javascript // plugins/distribution-engine/index.js export default { name: 'distribute-engine', version: '1.0.0', description: 'Multi-channel content distribution engine', hooks: { 'content:published': async ({ content, db, kv, env }) => { return distributeContent(content, db, kv); }, 'content:updated': async ({ content, db, kv, env }) => { // Re-distribute to all channels with updated content await cancelPendingDistributions(content.id, db); return distributeContent(content, db, kv); }, 'content:unpublished': async ({ content, db, kv, env }) => { // Delete from syndicated channels return retractContent(content.id, content.slug, db); }, }, adminUI: { menuItem: 'Distribution', component: './admin/DistributionDashboard.jsx', route: '/admin/plugins/distribution-engine', }, }; ``` ### Distribution Worker The core distribution logic creates queue entries, deduplicates, and kicks off processing: ```javascript async function distributeContent(content, db, kv) { const channels = await resolveChannels(content, db); const queueEntries = channels.map(channel => ({ id: crypto.randomUUID(), content_id: content.id, content_type: content.type, channel: channel.id, status: 'pending', payload: JSON.stringify({ title: content.title, body: content.body, excerpt: content.excerpt, slug: content.slug, cover_image: content.cover_image, tags: content.tags, author: content.author, meta: content.meta || {}, }), attempts: 0, max_attempts: 3, created_at: new Date().toISOString(), })); const stmt = db.prepare(` INSERT INTO distribution_queue (id, content_id, content_type, channel, status, payload, attempts, max_attempts, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); await db.batch(queueEntries.map(entry => stmt.bind( entry.id, entry.content_id, entry.content_type, entry.channel, entry.status, entry.payload, entry.attempts, entry.max_attempts, entry.created_at ))); // Queue immediate processing via Cloudflare Queues or Cron await kv.put(`dist:${content.id}`, JSON.stringify({ queueIds: queueEntries.map(e => e.id), channels: queueEntries.map(e => e.channel), status: 'queued', })); return { queued: queueEntries.length, entries: queueEntries }; } async function resolveChannels(content, db) { // Check content metadata for explicit channel targeting if (content.meta?.channels) { const channelConfigs = await db.prepare( `SELECT * FROM distribution_channels WHERE id IN (${content.meta.channels.map(() => '?').join(',')})` ).bind(...content.meta.channels).all(); return channelConfigs.results; } // Default: publish to all active channels const allChannels = await db.prepare( 'SELECT * FROM distribution_channels WHERE active = 1' ).all(); return allChannels.results; } ``` ### Channel Adapters Each channel adapter receives the content payload and transforms it appropriately: ```javascript // adapters/blog.adapter.js export default { id: 'blog', name: 'Main Blog', async dispatch(payload, db, kv) { // Blog adapter: content stays in EmDash as Astro markdown // No transformation needed — EmDash already renders it return { success: true, message: 'Content published on main blog', url: `/blog/${payload.slug}`, }; }, }; ``` ```javascript // adapters/newsletter.adapter.js export default { id: 'newsletter', name: 'Weekly Newsletter', async dispatch(payload, db, kv) { const emailHtml = await renderNewsletterTemplate({ title: payload.title, excerpt: payload.excerpt, body: truncateHtml(payload.body, 500), url: `${BASE_URL}/blog/${payload.slug}`, cover_image: payload.cover_image, }); const result = await sendEmail({ to: kv.get('newsletter:list-id'), subject: `New on the blog: ${payload.title}`, html: emailHtml, tags: ['blog-digest', ...(payload.tags || [])], }); return { success: result.accepted.length > 0, message: `Sent to ${result.accepted.length} recipients`, recipient_count: result.accepted.length, }; }, }; ``` ```javascript // adapters/linkedin.adapter.js export default { id: 'linkedin', name: 'LinkedIn', async dispatch(payload, db, kv) { const linkedinPost = formatLinkedInPost({ headline: payload.title, body: buildLinkedInBody(payload.excerpt, payload.slug), image: payload.cover_image, }); const result = await fetch('https://api.linkedin.com/v2/ugcPosts', { method: 'POST', headers: { 'Authorization': `Bearer ${await kv.get('auth:linkedin:token')}`, 'Content-Type': 'application/json', 'X-Restli-Protocol-Version': '2.0.0', }, body: JSON.stringify(linkedinPost), }); const data = await result.json(); return { success: result.ok, message: data.id ? 'Posted to LinkedIn' : 'LinkedIn API error', url: data.id ? `https://linkedin.com/feed/update/${data.id}` : null, response_code: result.status, }; }, }; ``` ### Queue Processor (Cron Worker) A cron-triggered Cloudflare Worker processes pending queue entries: ```javascript export default { async scheduled(event, env, ctx) { const { db, kv } = createBindings(env); const processors = loadAdapters(); const pending = await db.prepare(` SELECT * FROM distribution_queue WHERE status IN ('pending', 'retrying') AND (next_retry_at IS NULL OR next_retry_at <= datetime('now')) ORDER BY created_at ASC LIMIT 50 `).all(); for (const entry of pending.results) { const adapter = processors.get(entry.channel); if (!adapter) { await markFailed(entry.id, `No adapter for channel: ${entry.channel}`, db); continue; } const start = Date.now(); try { const payload = JSON.parse(entry.payload); const result = await adapter.dispatch(payload, db, kv); await db.prepare(` UPDATE distribution_queue SET status = 'completed', completed_at = datetime('now') WHERE id = ? `).bind(entry.id).run(); await db.prepare(` INSERT INTO distribution_log (id, queue_id, content_id, channel, status, response_code, response_body, duration_ms, attempted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) `).bind( crypto.randomUUID(), entry.id, entry.content_id, entry.channel, 'success', result.response_code || 200, JSON.stringify(result), Date.now() - start ).run(); } catch (error) { const attempts = entry.attempts + 1; const shouldRetry = attempts < entry.max_attempts; await db.prepare(` UPDATE distribution_queue SET status = ?, attempts = ?, next_retry_at = ?, error = ? WHERE id = ? `).bind( shouldRetry ? 'retrying' : 'failed', attempts, shouldRetry ? getRetryAt(attempts) : null, error.message, entry.id ).run(); } } }, }; function getRetryAt(attempt) { // Exponential backoff: 30s, 2min, 8min const delays = [30, 120, 480]; const delay = delays[attempt - 1] || 1800; return new Date(Date.now() + delay * 1000).
Key Takeaways
- **Centralized distribution** — a single EmDash plugin replaces manual multi-channel publishing
- **Queue-based architecture** — D1 as a distribution queue ensures reliability and retry logic
- **Channel adapters** — pluggable adapters for blog, newsletter, and social platforms
- **Observability built in** — tracking every distribution event for analytics and debugging