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
.mdfile 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:
- Encoding issues – the
catinsidejqmangled Unicode characters. - Hard‑coded paths – every new day required manual directory creation.
- 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

![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)