Automating Multi‑Channel Content Publishing with a Simple Node Script

TL;DR: I added a lightweight Node / npm‑based generator that creates platform‑specific files (Markdown for Medium/Substack, JSON for Bluesky) from a single source template. The change cuts manual copy‑paste time by ~80 % and keeps publishing metadata in sync.

The Problem

My weekly sprint for the content‑automation project required me to publish the same article on three platforms:

  • Medium – expects a .md file with front‑matter.
  • Substack – also consumes Markdown but with a slightly different header format.
  • Bluesky – requires a JSON payload with an array of “post” objects.

Previously I was manually copying the article body into each format, adjusting headings, and updating a metadata.json file by hand. The symptom was obvious in the commit history: a flood of almost‑identical markdown files and a growing metadata.json drift. The biggest pain point showed up as a CI failure when the metadata.json flag medium_generated was still false after I had already pushed the Medium post.

{ "medium_generated": false, "substack_generated": false, ... } 

The mismatch caused the CI pipeline to abort, and I realized the process needed automation.

What I Tried First

My first attempt was to write a Bash one‑liner that sed‑ed the source markdown into the different formats and then jq‑ed the JSON for Bluesky. The script looked like this:

#!/usr/bin/env bash SRC="content/2026/10/26/content-automation/article.md" # Medium sed 's/^# /## /' "$SRC" > "content/$(date +%Y/%m/%d)/content-automation/medium_en.md" # Substack (same as Medium for now) cp "content/$(date +%Y/%m/%d)/content-automation/medium_en.md" \ "content/$(date +%Y/%m/%d)/content-automation/substack_en.md" # Bluesky JSON jq -n --arg text "$(cat "$SRC")" '[{type:"progress", text:$text}]' \ > "content/$(date +%Y/%m/%d)/content-automation/bluesky_en.json" 

It worked for a single article, but it quickly broke:

  1. Encoding issues – the cat inside jq mangled Unicode characters.
  2. Hard‑coded paths – every new day required manual directory creation.
  3. No error handling – the script silently overwrote files.

Because of these flaws, the script was discarded after the first failed run.

The Implementation

I rewrote the generator in Node.js, which gave me better string handling, asynchronous file I/O, and the ability to reuse a single source template (article.template.md). The core of the solution lives in scripts/generate-content.js. Below is a trimmed version that shows the essential steps.

File: scripts/generate-content.js

#!/usr/bin/env node import fs from 'fs/promises'; import path from 'path'; import { format } from 'date-fns'; import { execSync } from 'child_process'; // ---------- Config ---------- const ROOT = path.resolve('content'); const DATE = new Date(); // today const DATE_PATH = format(DATE, 'yyyy/MM/dd'); const DEST = path.join(ROOT, DATE_PATH, 'content-automation'); // ---------- Helpers ---------- async function ensureDir(dir) { await fs.mkdir(dir, { recursive: true }); } function readTemplate() { return fs.readFile( path.join('templates', 'article.template.md'), 'utf8' ); } // ---------- Main ---------- (async () => { await ensureDir(DEST); const raw = await readTemplate(); // 1️⃣ Medium (adds front‑matter) const medium = `---\ntitle: "Weekly Build‑in‑Public: Content‑Automation Sprint"\ndate: ${format( DATE, "yyyy-MM-dd" )}\n---\n\n${raw}`; await fs.writeFile(path.join(DEST, 'medium_en.md'), medium); console.log('✅ medium_en.md generated'); // 2️⃣ Substack (different front‑matter style) const substack = `---\nsubject: "Weekly Build‑in‑Public: Content‑Automation Sprint"\n---\n\n${raw}`; await fs.writeFile(path.join(DEST, 'substack_en.md'), substack); console.log('✅ substack_en.md generated'); // 3️⃣ Bluesky JSON payload const bluesky = [ { type: 'progress', text: raw.replace(/\n/g, '\\n'), // escape newlines for JSON string }, ]; await fs.writeFile( path.join(DEST, 'bluesky_en.json'), JSON.stringify(bluesky, null, 2) ); console.log('✅ bluesky_en.json generated'); // 4️⃣ Update metadata.json atomically const metaPath = path.join(ROOT, 'metadata.json'); const metaRaw = await fs.readFile(metaPath, 'utf8'); const meta = JSON.parse(metaRaw); meta.medium_generated = true; meta.substack_generated = true; meta.bluesky_generated = true; await fs.writeFile(metaPath, JSON.stringify(meta, null, 2)); console.log('✅ metadata.json updated'); // 5️⃣ Optional: auto‑commit the changes (CI‑friendly) execSync('git add .', { stdio: 'inherit' }); execSync( `git commit -m "chore(content): auto‑generate ${format( DATE, 'yyyy-MM-dd' )}"`, { stdio: 'inherit' } ); })(); 

Why this works

Decision Reason
Node.js + ES modules Native async/await, clean import syntax, no external runtime needed on CI.
fs/promises Guarantees non‑blocking I/O, easier error handling with try/catch.
date-fns Small footprint for date formatting; avoids heavy Moment.js.
Atomic metadata.json update Reads the whole file, flips flags, writes back in one step – eliminates race conditions.
Auto‑commit Keeps the repo in sync with CI expectations (medium_generated flag).

Generated Files (excerpt)

content/2026/10/27/content-automation/bluesky_en.json

[ { "type": "progress", "text": "I was halfway through a two‑week sprint on **albaida‑tickets** when I realized the dashboard I’d been polishing for days didn’t fit in anyone’s pocket. The grid of cards, the side‑b..." } ] 

content/2026/10/27/content-automation/medium_en.md (first 5 lines)

--- title: "Weekly Build‑in‑Public: Content‑Automation Sprint" date: 2026-10-27 --- I was halfway through a two‑week sprint on **albaida‑tickets** when I realized the dashboard I’d been polishing for days didn’t fit in anyone’s pocket... 

content/2026/10/27/content-automation/metadata.json (relevant section)

{ "pull_requests": 0, "releases": 0, "closed_issues": 0, "medium_generated": true, "substack_generated": true, "bluesky_generated": true } 

All three platform files are now created with a single command:

npm run generate-content # defined in package.json as "node scripts/generate-content.js" 

The CI pipeline now checks only the boolean flags in metadata.json. If any flag is false, the build fails

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-28

#playadev #buildinpublic