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
Top comments (0)