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