DEV Community

Automating Multi‑Platform Content Publishing with a Node.js Generator

TL;DR: I added a small Node.js generator that creates platform-specific markdown/JSON files (Medium, Substack, Bluesky) from a single source and updates metadata.json automatically.

The change eliminates manual copy-paste, reduces human error, and makes the weekly “build-in-public” workflow reproducible.

The Problem

Every week I write a single article that needs to be posted on Medium, Substack, and Bluesky.

The original workflow looked like this:

  • Write the article in a local content/YYYY/MM/DD/<topic>/article.md.
  • Copy-paste the body into three different editors (Medium UI, Substack UI, Bluesky API client).
  • Manually edit front-matter for each platform (titles, tags, language).
  • Open metadata.json and flip the *_generated flags.

The pain points were obvious:

  • Human error - a missing tag or a stray markdown element broke the post on one platform.
  • Time waste - 30-45 minutes of repetitive copy-pasting per article.
  • Inconsistent state - metadata.json often fell out of sync, causing CI pipelines that check the flag to fail.

The error that finally forced a change was a CI failure that printed:

Error: Expected metadata.generated to be true for platform "medium", but got false.

The CI job halted the release pipeline for the whole week.

What I Tried First

My first attempt was a bash script that used sed to replace placeholders and cp to duplicate the source file:

#!/bin/bash
SRC="content/2026/10/24/albaida-tickets/article.md"
cp $SRC content/2026/10/25/content-automation/medium_en.md
sed -i 's/{{title}}/My Title EN/g' content/2026/10/25/content-automation/medium_en.md
# repeat for substack and bluesky …

What went wrong:

  • Platform-specific quirks - Bluesky expects a JSON payload, not markdown, so sed could not generate the correct structure.
  • Cross-platform portability - sed -i behaves differently on macOS vs. Linux, breaking CI on the runner.
  • No atomicity - If the script crashed after copying the Medium file but before updating metadata.json, the repo was left in a half-generated state.

The script was fragile, hard to extend, and produced the same CI error as before.

The Implementation

I rewrote the generator in Node.js (v20), using native fs/promises and a tiny helper library gray-matter to parse front-matter. The script lives in scripts/generate-content.js and is invoked by an npm task npm run generate:content.

Architecture Decisions

  • Single source markdown (source.md) - Guarantees a single truth source; all platforms derive from it.
  • Platform adapters (adapters/*.js) - Isolate platform-specific formatting (JSON vs. markdown).
  • Atomic write (fs.writeFile with temp file + rename) - Prevent half-generated states on crash.
  • Metadata sync (metadata.json update in same transaction) - Guarantees the *_generated flags always reflect reality.
  • TypeScript (.ts files) - Early catching of shape mismatches between adapters.

Below is a trimmed version of the generator and the adapters that produced the files shown in the diff.

scripts/generate-content.ts:

import { readFile, writeFile, rename, mkdir } from 'fs/promises';
import matter from 'gray-matter';
import path from 'path';
import { generateMedium } from '../adapters/medium';
import { generateSubstack } from '../adapters/substack';
import { generateBluesky } from '../adapters/bluesky';
import { updateMetadata } from '../utils/metadata';

async function main() {
  const srcPath = path.resolve('content/2026/10/24/albaida-tickets/article.md');
  const raw = await readFile(srcPath, 'utf-8');
  const { data: front, content } = matter(raw);
  // Destination folder (date-based)
  const destDir = path.resolve('content/2026/10/25/content-automation');
  await mkdir(destDir, { recursive: true });
  // 1️⃣ Medium (EN & ES)
  await writeAtomic(path.join(destDir, 'medium_en.md'), generateMedium(content, front, 'en'));
  await writeAtomic(path.join(destDir, 'medium_es.md'), generateMedium(content, front, 'es'));
  // 2️⃣ Substack (EN & ES)
  await writeAtomic(path.join(destDir, 'substack_en.md'), generateSubstack(content, front, 'en'));
  await writeAtomic(path.join(destDir, 'substack_es.md'), generateSubstack(content, front, 'es'));
  // 3️⃣ Bluesky (EN & ES) - JSON payloads
  await writeAtomic(path.join(destDir, 'bluesky_en.json'), JSON.stringify(generateBluesky(content, front, 'en'), null, 2));
  await writeAtomic(path.join(destDir, 'bluesky_es.json'), JSON.stringify(generateBluesky(content, front, 'es'), null, 2));
  // 4️⃣ Changelog entry
  const changelogPath = path.join(destDir, 'changelog.md');
  const changelogEntry = `## [${new Date().toISOString().split('T')[0]}] content-automation ### Added - Generated platform files for Albaida tickets series `;
  await writeAtomic(changelogPath, changelogEntry);
  // 5️⃣ Update metadata atomically
  await updateMetadata({ medium_generated: true, substack_generated: true, bluesky_generated: true, });
}

async function writeAtomic(target: string, data: string) {
  const tmp = `${target}.tmp`;
  await writeFile(tmp, data, 'utf-8');
  await rename(tmp, target);
}

main().catch(err => {
  console.error('Generation failed:', err);
  process.exit(1);
});

Platform adapters (excerpt)

adapters/medium.ts:

export function generateMedium(body: string, front: any, locale: 'en' | 'es'): string {
  const title = locale === 'en' ? front.title_en : front.title_es;
  const tags = locale === 'en' ? front.tags_en : front.tags_es;
  const header = `---\ntitle: "${title}"\ntags: ${JSON.stringify(tags)}\n---\n\n`;
  return header + body;
}

adapters/substack.ts:

export function generateSubstack(body: string, front: any, locale: 'en' | 'es'): string {
  // Substack expects a short intro then the body.
  const intro = locale === 'en' ? front.intro_en : front.intro_es;
  return `${intro}\n\n${body}`;
}

adapters/bluesky.ts:

export interface BlueskyPayload { type: string; text: string; langs?: string[]; }
export function generateBluesky(body: string, front: any, locale: 'en' | 'es'): BlueskyPayload[] {
  const text = `${front.title_${locale}} - ${body.slice(0, 200)}…`;
  return [

Part of my Build in Public series - sharing the real process of building SaaS projects from Playa del Carmen, México.

Repo: zaerohell/content-automation · 2026-10-26

#playadev #buildinpublic

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.