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 
