Automating Multi‑Platform Content Publishing with a Node.js Pipeline
TL;DR: I built a Node.js script that generates daily changelogs and creates ready‑to‑post Markdown/JSON files for Medium, Substack, and Bluesky in a single run. The pipeline removes manual copy‑paste, keeps metadata in sync, and lets me push the same content to three platforms with one commit.
The Problem
Every morning I had to open three different editors (Medium, Substack, Bluesky) and copy‑paste the same article text, update the changelog, and tweak front‑matter manually. The process was error‑prone: a missing heading on Medium, a broken link on Substack, or an outdated metadata.json flag that prevented the CI from triggering the next step. The symptom was a CI job that kept failing with:
Error: metadata.json – medium_generated is false but file content/2026/10/29/content-automation/medium_en.md does not exist
In short, the source of truth was scattered across the repository, and any mismatch broke the automation that should have published the content.
What I Tried First
My first attempt was a simple Bash loop that copied the same Markdown file into each platform’s folder and manually edited the front‑matter. The script looked like this:
#!/bin/bash
cp content/template.md content/2026/10/29/content-automation/medium_en.md
cp content/template.md content/2026/10/29/content-automation/substack_en.md
# add front‑matter with sed
sed -i '1s/^/---\ntitle: My Post\n---\n/' content/2026/10/29/content-automation/medium_en.md
It worked for a single day, but as soon as I added a Spanish version and a daily changelog, the script became a tangled mess of sed commands, hard‑coded dates, and duplicated logic. The biggest failure was that the script didn’t update metadata.json, so the CI pipeline still thought the files weren’t generated.
The Implementation
Overview
I rewrote the whole pipeline in Node.js (v20) to keep everything in one language, use proper JSON handling, and make the code testable. The entry point is scripts/generateContent.js. It does three things:
-
Generate a daily changelog (
changelog.md) based on the previous day’s commit messages. -
Render platform‑specific Markdown/JSON from a single source template (
templates/article.hbs). -
Update
metadata.jsonto reflect which files were successfully generated.
All files live under content/2026/10/29/content-automation/ so the path is deterministic.
Core Files
| File | Purpose |
|---|---|
scripts/generateContent.js |
Main driver, orchestrates the pipeline |
templates/article.hbs |
Handlebars template for the article body |
utils/git.js |
Helper that runs git log to collect commit messages |
utils/fileWriter.js |
Thin wrapper around fs.promises with error handling |
content/2026/10/29/content-automation/metadata.json |
Stores generation flags (medium_generated, substack_generated, bluesky_generated) |
Step‑by‑Step Code Walkthrough
1. Collecting commit messages for the changelog
// utils/git.js
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export async function getYesterdayCommits() {
const { stdout } = await execAsync(
'git log --since="1 day ago" --pretty=format:"%h %s"'
);
return stdout.split('\n').filter(Boolean);
}
2. Writing the changelog
// scripts/generateContent.js (excerpt)
import { writeFile } from '../utils/fileWriter.js';
import { getYesterdayCommits } from '../utils/git.js';
import path from 'path';
async function generateChangelog() {
const commits = await getYesterdayCommits();
const changelog = [
'## [2026-10-29] content-automation',
'',
'### Added',
...commits.map(c => `- \`${c}\` – auto‑generated entry.`),
'',
].join('\n');
const changelogPath = path.resolve(
'content/2026/10/29/content-automation/changelog.md'
);
await writeFile(changelogPath, changelog);
}
The diff for this file (added in commit 08c22246) looks like:
+## [2026-10-29] content-automation
+
+### Added
+- `content/2026/10/28/content-automation/changelog.md` – registro de cambios del día 2026‑10‑28.
3. Rendering platform files
I use Handlebars to keep the article body DRY. The template (templates/article.hbs) contains placeholders for title, date, and body. Example snippet:
---
title: "{{title}}"
date: "{{date}}"
tags: {{tags}}
---
{{{body}}}
The rendering function:
// scripts/generateContent.js (excerpt)
import fs from 'fs/promises';
import Handlebars from 'handlebars';
import path from 'path';
async function renderArticle(platform, locale) {
const tmplPath = path.resolve('templates/article.hbs');
const tmplSrc = await fs.readFile(tmplPath, 'utf8');
const template = Handlebars.compile(tmplSrc);
const data = {
title: platform === 'bluesky' ? 'Progress Update' : 'Weekly Build‑in‑Public',
date: '2026-10-29',
tags: ['vibecoding', 'buildinpublic'],
body: await fs.readFile(
`content/2026/10/28/content-automation/${platform}_${locale}.md`,
'utf8'
).catch(() => ''), // fallback if language file missing
};
return template(data);
}
After rendering, the script writes the files:
await writeFile(
`content/2026/10/29/content-automation/medium_${locale}.md`,
await renderArticle('medium', locale)
);
await writeFile(
`content/2026/10/29/content-automation/substack_${locale}.md`,
await renderArticle('substack', locale)
);
await writeFile(
`content/2026/10/29/content-automation/bluesky_${locale}.json`,
JSON.stringify([
{
type: locale === 'en' ? 'progress' : 'avance',
text: `Finally got the script to auto‑generate the daily changelog in content/2026/10/28/content-automation/changelog.md. After a week of manual work, it finally works!`,
},
], null, 2)
);
The resulting JSON (from commit 6c902a6e) is:
[
{
"type": "progress",
"text": "Finally got the script to auto‑generate the daily changelog in content/2026/10/28/content-automation/changelog.md. After a week of manual work, it finally works!"
}
]
4. Updating metadata.json
The metadata file tracks which platforms have been generated
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-30
#playadev #buildinpublic
Top comments (0)