DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Article Publishing with a Node.js Content Pipeline

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 Entity when 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
Enter fullscreen mode Exit fullscreen mode

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: sed left 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

Why this code works

  1. Single source of truth – All platforms read from src/article.md. No copy‑paste.
  2. Front‑matter injection – Using gray-matter we prepend YAML that each platform expects (title for Medium, subject for Substack).
  3. Line‑ending normalization – The replace(/\r\n/g, '\n') eliminates the \r that broke Dev.to’s API.
  4. Idempotent manifest – Flags prevent re‑generating files that already exist, avoiding duplicate PRs.
  5. Extensible platform array – Adding a new platform only requires a new entry in the platforms array.

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

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

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

Top comments (0)