Automating Multi‑Platform Content Publishing with a Node.js Pipeline
TL;DR: I built a small Node.js pipeline that turns a single markdown source into ready‑to‑post files for Medium, Substack, and future platforms. The change lives in content/2026/10/28/content-automation/ and flips the medium_generated/substack_generated flags in metadata.json so the rest of the CI can skip already‑published drafts.
The Problem
Every week I wrote the same article three times: once in Medium’s rich editor, once in Substack’s markdown pane, and once in a plain‑text draft that I kept in my repo. The workflow looked like this:
- Write the article in a local
.mdfile. - Open three browser tabs, copy‑paste the content, tweak formatting for each platform.
- Manually toggle a “published” flag in a spreadsheet.
The friction was obvious: a single typo in one copy meant the article looked broken on at least one platform, and the manual toggles were a source of drift. In the CI logs I kept seeing the same error:
Error: content/2026/10/28/content-automation/medium_en.md not found
That was a symptom of the fact that the automation step that should have generated the file never ran, because the metadata still said "medium_generated": false.
What I Tried First
My first attempt was a naïve shell script (gen.sh) that concatenated a template with a date stamp and dumped the result into the three platform folders:
#!/bin/bash
TEMPLATE=templates/article.md
DATE=$(date +%Y-%m-%d)
for PLATFORM in medium substack; do
cp $TEMPLATE content/$DATE/content-automation/${PLATFORM}_en.md
done
It worked for the first run, but quickly broke:
- The script didn’t respect language variations (Spanish vs. English).
- It overwrote existing drafts without warning, erasing manual tweaks.
- No state was persisted, so the CI kept re‑running the generation step on every push.
I needed something more robust: a true pipeline that reads a metadata file, respects language, and only generates files that haven’t been produced yet.
The Implementation
1. File layout
content/
└─ 2026/
└─ 10/
└─ 28/
└─ content-automation/
├─ changelog.md
├─ medium_en.md
├─ medium_es.md
├─ substack_en.md
├─ substack_es.md
└─ metadata.json
scripts/
└─ generate.js
All platform‑specific markdown files live under content/2026/10/28/content-automation/. The metadata.json file tracks generation state:
{
"repo": "content-automation",
"date": "2026-10-28",
"languages": ["es", "en"],
"topics": ["Productivity"],
"commits": 3,
"pull_requests": 0,
"releases": 0,
"closed_issues": 0,
"medium_generated": true,
"substack_generated": true
}
The diff that introduced the flags looks like this (excerpt from the commit 0c8cf973):
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated": true,
2. The generator script
scripts/generate.js is the heart of the pipeline. It reads metadata.json, checks the flags, and writes the appropriate markdown files using a simple Mustache‑style template.
// scripts/generate.js
const fs = require('fs');
const path = require('path');
const META_PATH = path.join(__dirname, '..', 'content', '2026', '10', '28', 'content-automation', 'metadata.json');
const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'article.mustache');
const meta = JSON.parse(fs.readFileSync(META_PATH, 'utf8'));
const template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
function render(lang, platform) {
// Very small placeholder replacement – real code uses a Mustache library
return template
.replace('{{lang}}', lang)
.replace('{{platform}}', platform)
.replace('{{date}}', meta.date);
}
function writeFile(filename, content) {
const outPath = path.join(__dirname, '..', 'content', '2026', '10', '28', 'content-automation', filename);
fs.writeFileSync(outPath, content);
console.log(`✔️ ${filename} generated`);
}
// Generate only if the flag is false
if (!meta.medium_generated) {
meta.languages.forEach(lang => {
const fn = `medium_${lang}.md`;
writeFile(fn, render(lang, 'Medium'));
});
meta.medium_generated = true;
}
if (!meta.substack_generated) {
meta.languages.forEach(lang => {
const fn = `substack_${lang}.md`;
writeFile(fn, render(lang, 'Substack'));
});
meta.substack_generated = true;
}
// Persist the updated flags
fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2));
Key points:
-
Idempotency – The script checks the
*_generatedflags before writing, so re‑running it never overwrites a file that already exists. -
Language loop – By iterating over
meta.languageswe automatically support any new language without touching the script. - Simple templating – The Mustache‑style placeholders keep the content source single‑sourced; the same template feeds both Medium and Substack.
3. The template
templates/article.mustache holds the raw article body with placeholders for platform‑specific front‑matter:
{{#if (eq platform "Medium")}}
---
title: "Weekly Build‑in‑Public: Content‑Automation Sprint 🚀"
tags: ["vibecoding","buildinpublic","productivity"]
date: "{{date}}"
---
{{/if}}
{{#if (eq platform "Substack")}}
**Subject:** Weekly Build‑in‑Public: Content‑Automation Sprint 🚀
---
{{/if}}
{{#if (eq lang "es")}}
Cuando desperté el 27 de octubre...
{{else}}
I was staring at three open tabs—Medium’s editor, Substack’s draft pane...
{{/if}}
The generator injects the correct front‑matter for each platform, eliminating the manual copy‑paste that caused the original error.
4. CI integration
A GitHub Actions workflow (.github/workflows/content.yml) runs the generator on every push to main:
yaml
name: Content Automation
on:
push:
branches:
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/content-automation` · 2026-10-29*
\#playadev #buildinpublic
Top comments (0)