Automating Multi‑Platform Article Publishing with a Node.js Content Pipeline
TL;DR: I built a tiny Node.js pipeline that pulls a single source markdown, injects front‑matter, writes platform‑specific files, and flips flags in metadata.json. The result is a reproducible, zero‑touch publish step that keeps Medium, Substack, and Dev.to in sync.
The Problem
Every week I write a single article in a src/ folder, then manually copy‑paste it into three different publishing platforms (Medium, Substack, Dev.to). The process was fragile:
- Missed line breaks caused malformed HTML on Medium.
- Substack rejected the file because the front‑matter was missing.
- Dev.to’s API returned
422 Unprocessable Entitywhen the article body contained stray carriage returns (\r).
The symptom was a cascade of alerts in ClickUp: “Medium draft not generated”, “Substack draft missing”, “Dev.to build failed”. I needed a reliable way to generate the three platform‑specific markdown files from a single source and keep a JSON manifest up‑to‑date.
What I Tried First
My first attempt was a Bash one‑liner that used sed to replace placeholders:
sed "s/{{date}}/$(date +%F)/" src/article.md > content/2026/10/04/content-automation/medium_en.md
It worked for the date placeholder, but quickly fell apart:
-
Path brittleness: Hard‑coded dates (
2026/10/04) meant the script broke on the next day. -
Encoding issues:
sedleft Windows line endings (\r\n) in the output, causing Dev.to’s API to reject the payload. - No state tracking: I had no way to know which platforms had already been generated, leading to duplicate PRs.
Because the script was not extensible, I abandoned it and started building a proper Node.js tool.
The Implementation
Project layout
content-automation/
├─ src/
│ └─ article.md # single source (raw markdown)
├─ scripts/
│ └─ generate.js # pipeline entry point
├─ content/
│ └─ 2026/10/04/content-automation/
│ ├─ medium_en.md
│ ├─ medium_es.md
│ ├─ substack_en.md
│ ├─ substack_es.md
│ └─ metadata.json
└─ package.json
metadata.json – the source of truth
Before the change metadata.json looked like this (excerpt from the diff):
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated":
I expanded it to a full manifest that tracks each platform:
{
"repo": "content-automation",
"date": "2026-10-04",
"languages": ["es", "en"],
"topics": ["Productivity"],
"commits": 3,
"pull_requests": 0,
"releases": 0,
"closed_issues": 0,
"generated": {
"medium_en": false,
"medium_es": false,
"substack_en": false,
"substack_es": false
}
}
The pipeline updates these flags after a successful write, so the next run knows what’s already been produced.
Core script – scripts/generate.js
// scripts/generate.js
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
// Load manifest
const manifestPath = path.resolve(__dirname, '../content/2026/10/04/content-automation/metadata.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// Load source article
const srcPath = path.resolve(__dirname, '../src/article.md');
const raw = fs.readFileSync(srcPath, 'utf8');
// Helper to write a platform file
function writePlatform({ platform, lang, frontMatter }) {
const outDir = path.resolve(__dirname, `../content/2026/10/04/content-automation`);
const filename = `${platform}_${lang}.md`;
const outPath = path.join(outDir, filename);
const { content } = matter.stringify(raw, frontMatter);
// Ensure Unix line endings
const normalized = content.replace(/\r\n/g, '\n');
fs.writeFileSync(outPath, normalized, 'utf8');
console.log(`✅ ${filename} generated`);
// Flip flag in manifest
manifest.generated[`${platform}_${lang}`] = true;
}
// Platform definitions
const platforms = [
{
platform: 'medium',
lang: 'en',
frontMatter: { title: "'When a deadline turned into a cascade of alerts', tags: ['Productivity'] }"
},
{
platform: 'medium',
lang: 'es',
frontMatter: { title: "'Cuando el reloj marcó las 22:00 y mi inbox ya estaba lleno', tags: ['Productividad'] }"
},
{
platform: 'substack',
lang: 'en',
frontMatter: { subject: 'Week in Review – Content‑Automation, AI‑driven drafts & a ClickUp fix 🚧' }
},
{
platform: 'substack',
lang: 'es',
frontMatter: { subject: '🚧 Esta semana en *content‑automation*: IA, alertas y un fix de ClickUp' }
}
];
// Run generation only for missing files
platforms.forEach(p => {
const flag = manifest.generated[`${p.platform}_${p.lang}`];
if (!flag) writePlatform(p);
});
// Persist updated manifest
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
console.log('🔧 manifest updated');
Why this code works
-
Single source of truth – All platforms read from
src/article.md. No copy‑paste. -
Front‑matter injection – Using
gray-matterwe prepend YAML that each platform expects (titlefor Medium,subjectfor Substack). -
Line‑ending normalization – The
replace(/\r\n/g, '\n')eliminates the\rthat broke Dev.to’s API. - Idempotent manifest – Flags prevent re‑generating files that already exist, avoiding duplicate PRs.
-
Extensible platform array – Adding a new platform only requires a new entry in the
platformsarray.
Diff highlights
The commit that added the new metadata.json structure and the generation script introduced the following diff (excerpt):
+ "generated": {
+ "medium_en": false,
+ "medium_es": false,
+ "substack_en": false,
+ "substack_es": false
+ }
And the newly added files:
+ scripts/generate.js
+ src/article.md
+ content/2026/10/04/content-automation/medium_en.md
+ content/2026/10/04/content-automation/medium_es.md
+ content/2026/10/04/content-automation/substack_en.md
+ content/2026/10/04/content-automation/substack_es.md
Running node scripts/generate.js on 2026‑10‑05 produced the exact files now present in the repo, as reflected in the auto‑generated changelog entry:
markdown
## [2026‑10‑04] content‑automation
### Added
- `content
---
*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-05*
\#playadev #buildinpublic
Top comments (0)