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:
- Copy‑pasting the article body into three separate editors.
- Adding platform‑specific front‑matter or JSON wrappers.
- Updating
metadata.jsonby 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
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
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:
-
Path brittleness – The script assumed the folder structure (
content/2026/10/26/...) would never change. A single rename broke the whole pipeline. -
JSON formatting errors –
jqproduced a trailing comma in the generatedbluesky_en.json, which the Bluesky API rejected withInvalid 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:
- Reads a single source markdown file (
src/articles/2026-10-27.md). - Generates platform‑specific files using template functions.
- Updates
content/2026/10/26/content-automation/metadata.jsonatomically. - 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');
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
templatesobject. - 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)