Automating Multi‑Platform Blog Publishing from a Single Markdown Source (Node.js)
TL;DR: I built a small Node.js pipeline that reads one source folder per article and emits ready‑to‑post markdown files for Dev.to, Medium, Substack and Bluesky. The change eliminates manual copy‑paste, keeps metadata in sync, and fixes the “ENOTEMPTY: directory not empty” errors that were breaking my Docker‑based CI builds.
The Problem
Every week I write a weekly “build log” that I want to publish on four different platforms. My previous workflow was:
- Write the article in a master
README.md. - Manually copy‑paste sections into separate files (
medium_en.md,substack_es.md, etc.). - Update the
metadata.jsonfor each platform by hand.
The result was a fragmented history and out‑of‑sync versions. The biggest pain point surfaced when our Docker CI runner tried to clean the content/ folder before a new run and threw:
ENOTEMPTY: directory not empty, rmdir '/workspace/content/2026/09/01/VS'
Because the folder still contained leftover generated files from the previous run, the build failed before any tests executed. I needed a deterministic, repeatable way to generate the platform‑specific files and guarantee a clean workspace each time.
What I Tried First
My first attempt was a quick Bash script that copied the master markdown into each target file:
#!/usr/bin/env bash
cp content/2026/09/01/VS/master.md content/2026/09/01/VS/medium_en.md
cp content/2026/09/01/VS/master.md content/2026/09/01/VS/substack_es.md
# …
It worked for a single article, but it introduced two major issues:
-
No metadata handling – I still had to edit
metadata.jsonmanually. -
Race conditions – When the CI runner executed the script in parallel for multiple dates, the
cpcommands sometimes clobbered each other, leaving partially written files and triggering theENOTEMPTYerror on the next run.
The script also didn’t give me any visibility into what changed, so I couldn’t track the diff in the repo.
The Implementation
1. Architecture Overview
src/
├─ generate-content.js # Core generator (Node.js)
└─ utils/
└─ markdown.js # Helpers for front‑matter & section extraction
content/
└─ 2026/
└─ 09/
└─ 01/
└─ VS/
├─ source.md # Single source article
├─ metadata.json # Platform‑specific URLs, tags, etc.
├─ medium_en.md # ← generated
├─ medium_es.md # ← generated
├─ substack_en.md # ← generated
└─ substack_es.md # ← generated
The generator reads source.md and metadata.json, then writes the platform files. It also cleans the target folder at the start of each run, guaranteeing a fresh state.
2. metadata.json Example
{
"title": "Replacing a discontinued Groq model – how I restored all AI‑powered features in VS",
"date": "2026-09-01",
"tags": ["nodejs", "automation", "content"],
"devto_url": "https://dev.to/zaerohell/replacing-a-discontinued-groq-model-how-i-restored-all-ai-powered-features-in-vs-3j5g",
"medium_url": "https://medium.com/@zaerohell/replacing-groq-model-2026",
"bluesky_uris": {},
"substack_url": "https://zaerohell.substack.com/p/replacing-groq-model"
}
3. Core Generator (src/generate-content.js)
#!/usr/bin/env node
import { readFile, writeFile, rm, mkdir } from 'fs/promises';
import path from 'path';
import { extractFrontMatter, renderForPlatform } from './utils/markdown.js';
const ROOT = path.resolve(import.meta.url, '../../content');
const DATE = process.argv[2] || '2026/09/01';
const ARTICLE = process.argv[3] || 'VS';
async function cleanTarget(dir) {
// Remove everything inside the article folder (but keep the folder itself)
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { recursive: true });
}
async function generate() {
const basePath = path.join(ROOT, DATE, ARTICLE);
const sourcePath = path.join(basePath, 'source.md');
const metaPath = path.join(basePath, 'metadata.json');
const [sourceRaw, metaRaw] = await Promise.all([
readFile(sourcePath, 'utf8'),
readFile(metaPath, 'utf8')
]);
const meta = JSON.parse(metaRaw);
const { frontMatter, body } = extractFrontMatter(sourceRaw);
// Ensure a clean slate (prevents ENOTEMPTY)
await cleanTarget(basePath);
// Platform definitions
const platforms = [
{ name: 'medium_en', lang: 'en' },
{ name: 'medium_es', lang: 'es' },
{ name: 'substack_en', lang: 'en' },
{ name: 'substack_es', lang: 'es' }
];
for (const p of platforms) {
const rendered = renderForPlatform(body, p.lang, meta);
const outPath = path.join(basePath, `${p.name}.md`);
await writeFile(outPath, `${frontMatter}\n${rendered}`);
console.log(`✅ ${p.name} generated`);
}
}
generate().catch(err => {
console.error('❌ Generation failed:', err);
process.exit(1);
});
Key parts:
-
cleanTargetusesrm(..., { recursive: true, force: true })which safely removes any leftover files, eliminating theENOTEMPTYerror. -
extractFrontMatterkeeps the YAML header intact across platforms. -
renderForPlatformswaps language‑specific strings (e.g., “When the AI model went silent…” vs. “Cuando el modelo de IA se quedó en silencio…”) based on a simple lookup table defined inutils/markdown.js.
4. Helper (src/utils/markdown.js)
js
export function extractFrontMatter(md) {
const fmMatch = md.match(/^---\n([\s\S]*?)\n---\n/);
const frontMatter = fmMatch ? fmMatch[0] : '';
const body = fmMatch ? md.slice(fmMatch[0].length) : md;
return { frontMatter, body };
}
// Very small i18n map – expand as needed
const translations = {
en: {
aiSilent: 'When
---
*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-09-02*
\#playadev #buildinpublic
Top comments (0)