DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Blog Publishing from a Single Markdown Source (Node.js)

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:

  1. Write the article in a master README.md.
  2. Manually copy‑paste sections into separate files (medium_en.md, substack_es.md, etc.).
  3. Update the metadata.json for 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'
Enter fullscreen mode Exit fullscreen mode

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
# …
Enter fullscreen mode Exit fullscreen mode

It worked for a single article, but it introduced two major issues:

  1. No metadata handling – I still had to edit metadata.json manually.
  2. Race conditions – When the CI runner executed the script in parallel for multiple dates, the cp commands sometimes clobbered each other, leaving partially written files and triggering the ENOTEMPTY error 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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

Key parts:

  • cleanTarget uses rm(..., { recursive: true, force: true }) which safely removes any leftover files, eliminating the ENOTEMPTY error.
  • extractFrontMatter keeps the YAML header intact across platforms.
  • renderForPlatform swaps 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 in utils/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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)