DEV Community

Roberto Luna
Roberto Luna

Posted on

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

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:

  1. Write the article in a local .md file.
  2. Open three browser tabs, copy‑paste the content, tweak formatting for each platform.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

Key points:

  • Idempotency – The script checks the *_generated flags before writing, so re‑running it never overwrites a file that already exists.
  • Language loop – By iterating over meta.languages we 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}}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)