DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Blog Publishing with a Content‑Generation Pipeline

Automating Multi‑Platform Blog Publishing with a Content‑Generation Pipeline

TL;DR: I built a small Node.js pipeline that generates platform‑specific markdown/JSON files from a single source and updates the repo metadata in one commit. The change lets VibeCoding push to Medium, Substack and Bluesky automatically, cutting manual copy‑paste time to seconds.


The Problem

Our weekly “Build‑in‑Public” sprint requires publishing the same article on three different channels:

Platform Required format Publication flow
Medium Markdown (.md) with front‑matter devto → medium script
Substack Markdown (.md) with a short header devto → substack script
Bluesky JSON array of thread objects devto → bluesky script

Before the automation, I was manually:

  1. Copy‑pasting the article body into three separate editors.
  2. Adding platform‑specific front‑matter or JSON wrappers.
  3. Updating metadata.json by hand to flag which platforms had been generated.

The biggest pain point was state drift: after a typo in one file, the other two would still be marked as “generated”, causing the CI to think the week’s content was complete when it wasn’t. The symptom was a failing CI job that printed:

Error: Expected generated flag for bluesky to be false, got true
Enter fullscreen mode Exit fullscreen mode

What I Tried First

My first attempt was a bash one‑liner that concatenated the source markdown with a few sed commands to inject the needed front‑matter:

cat src/article.md | \
  sed '1i---\ntitle: "Weekly Build‑in‑Public"\n---' > medium/2026-10-27.md
Enter fullscreen mode Exit fullscreen mode

I ran the same pattern for Substack and then used jq to wrap the content for Bluesky. It worked for the first iteration, but soon hit two blockers:

  1. Path brittleness – The script assumed the folder structure (content/2026/10/26/...) would never change. A single rename broke the whole pipeline.
  2. JSON formatting errors – jq produced a trailing comma in the generated bluesky_en.json, which the Bluesky API rejected with Invalid JSON payload.

Because the script was a single line, debugging was painful, and I couldn’t easily add unit tests.

The Implementation

I rewrote the automation as a modular Node.js CLI (scripts/generate-content.js). The CLI:

  1. Reads a single source markdown file (src/articles/2026-10-27.md).
  2. Generates platform‑specific files using template functions.
  3. Updates content/2026/10/26/content-automation/metadata.json atomically.
  4. Writes a changelog entry for traceability.

Below is the core of the new implementation (excerpt from the diff).

+// scripts/generate-content.js
+const fs = require('fs');
+const path = require('path');
+const matter = require('gray-matter');
+
+// 1️⃣ Load source article
+const srcPath = path.resolve(__dirname, '../src/articles/2026-10-27.md');
+const src = fs.readFileSync(srcPath, 'utf-8');
+const { content, data: frontMatter } = matter(src);
+
+// 2️⃣ Platform templates
+const templates = {
+  medium: (body) => `---\n${JSON.stringify(frontMatter, null, 2)}\n---\n\n${body}`,
+  substack: (body) => `---\n${JSON.stringify(frontMatter, null, 2)}\n---\n\n${body}`,
+  bluesky: (body) => [
+    { type: 'progress', text: `New post: ${frontMatter.title}` },
+    { type: 'text', markdown: body }
+  ]
+};
+
+// 3️⃣ Write files
+function writeFile(platform, ext, serializer) {
+  const outDir = path.resolve(__dirname, `../content/2026/10/26/content-automation`);
+  const outPath = path.join(outDir, `${platform}_${ext}`);
+  const payload = serializer(content);
+  const data = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
+  fs.writeFileSync(outPath, data);
+  console.log(`✅ ${platform} file generated → ${outPath}`);
+}
+
+writeFile('medium', 'en.md', templates.medium);
+writeFile('substack', 'en.md', templates.substack);
+writeFile('bluesky', 'en.json', templates.bluesky);
+
+// 4️⃣ Update metadata atomically
+const metaPath = path.resolve(__dirname, '../content/2026/10/26/content-automation/metadata.json');
+const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
+meta.medium_generated = true;
+meta.substack_generated = true;
+meta.bluesky_generated = true;
+fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
+console.log('🔄 metadata.json updated');
Enter fullscreen mode Exit fullscreen mode

File Changes Introduced

File Purpose Diff snippet
content/2026/10/26/content-automation/medium_en.md Generated Medium article (English)


diff<br>+I was halfway through a two‑week sprint on **albaida‑tickets** when I realized the dashboard…

|
| content/2026/10/26/content-automation/substack_en.md | Generated Substack newsletter |

diff<br>+**Subject:** Weekly Build‑in‑Public: Content Automation Sprint 🚀

|
| content/2026/10/26/content-automation/bluesky_en.json | JSON thread for Bluesky |

diff<br>+[<br>+ {<br>+ "type": "progress",<br>+ "text": "Finally generated the bluesky_en.json file that powers today's post on Bluesky."<br>+ },<br>+ {<br>+ "type": "text",<br>+ "markdown": "…" <br>+ }<br>+]

|
| content/2026/10/26/content-automation/metadata.json | Central state flag |

diff<br>- "medium_generated": false,<br>- "substack_generated": false,<br>+ "medium_generated": true,<br>+ "substack_generated": true,

|
| content/2026/10/26/content-automation/changelog.md | Human‑readable log of the generation run |

diff<br>+## [2026-10-26] content-automation<br>+### Added<br>+- `content/2026/10/25/content-automation/changelog.md` – registro del cambio…

|

Why This Architecture Works

  • Single source of truth – All platforms derive from src/articles/2026-10-27.md. Changing the article once propagates everywhere.
  • Template functions – Adding a new platform (e.g., LinkedIn) only requires a new entry in the templates object.
  • Atomic metadata update – By reading, mutating,

Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.

Repo: zaerohell/content-automation · 2026-10-27

#playadev #buildinpublic

Top comments (0)