Automating Multi‑Platform Content Publishing with a Single Source of Truth
TL;DR: I refactored the content-automation repo to generate Medium, Substack and Bluesky posts from one set of markdown files, committing platform‑specific JSON payloads and syncing a metadata.json flag. The change eliminates manual copy‑paste, guarantees API‑compatible payloads, and lets the daily cron run reliably.
The Problem
Our daily content pipeline pulls raw markdown from content/2026/10/21/content-automation/ and publishes it to three different services:
| Platform | Required format |
|---|---|
| Medium | Markdown file (*.md) |
| Substack | Markdown file (*.md) |
| Bluesky | JSON payload (type, text, langs) |
Originally each platform had its own copy of the article. When the cron job that triggers the generator failed, we ended up with out‑of‑sync files and, more importantly, invalid Bluesky JSON that the API rejected (payload missing required field "text"). The symptom was a cascade of “post not published” alerts in Slack.
What I Tried First
The first attempt was a quick script that read the Medium markdown, duplicated it to Substack, and then performed a naïve fs.writeFileSync to create a Bluesky JSON by wrapping the whole markdown string:
// naive‑generator.js (first version)
const md = fs.readFileSync('medium_en.md', 'utf8');
fs.writeFileSync('bluesky_en.json', JSON.stringify({ text: md }));
What went wrong
-
Missing fields – Bluesky expects an array of objects with
typeandtext. The API rejected the payload. - Encoding issues – Some emojis and non‑ASCII characters were escaped incorrectly, breaking the visual output.
- Duplication – Maintaining three separate markdown copies meant any edit had to be mirrored manually, leading to drift.
The failure showed up in the CI logs as:
Error: Request failed with status code 400
Response body: {"error":"Invalid payload: missing required field 'type'"}
The Implementation
I rewrote the generator as a single source of truth pipeline:
-
Keep only one markdown per language (
medium_en.md,medium_es.md). -
Parse front‑matter (title, tags) using
gray-matter. -
Generate platform‑specific files:
- Copy the markdown to Substack (
substack_en.md,substack_es.md). - Build a proper Bluesky JSON payload (
bluesky_en.json,bluesky_es.json).
- Copy the markdown to Substack (
-
Update
metadata.jsonatomically to mark which platforms have been generated.
Directory layout after the change
content/
└─ 2026/
└─ 10/
└─ 21/
└─ content-automation/
├─ medium_en.md
├─ medium_es.md
├─ substack_en.md ← generated copy
├─ substack_es.md ← generated copy
├─ bluesky_en.json ← generated payload
├─ bluesky_es.json ← generated payload
└─ metadata.json
Core generator (scripts/generate.js)
// scripts/generate.js
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
// Helper to write JSON with pretty printing
const writeJSON = (file, obj) =>
fs.writeFileSync(file, JSON.stringify(obj, null, 2));
// Load markdown and front‑matter
const loadMD = (lang) => {
const mdPath = path.join(__dirname, `../content/2026/10/21/content-automation/medium_${lang}.md`);
const raw = fs.readFileSync(mdPath, 'utf8');
return matter(raw);
};
['en', 'es'].forEach((lang) => {
const { data, content } = loadMD(lang);
// 1️⃣ Substack copy (exact same markdown)
const substackPath = path.join(__dirname, `../content/2026/10/21/content-automation/substack_${lang}.md`);
fs.writeFileSync(substackPath, `---\n${JSON.stringify(data, null, 2)}\n---\n\n${content}`);
// 2️⃣ Bluesky payload
const blueskyPayload = [
{
type: 'text',
text: content,
},
{
type: 'tags',
tags: data.tags || [],
},
];
const blueskyPath = path.join(__dirname, `../content/2026/10/21/content-automation/bluesky_${lang}.json`);
writeJSON(blueskyPath, blueskyPayload);
});
// 3️⃣ Update metadata.json atomically
const metaPath = path.join(__dirname, '../content/2026/10/21/content-automation/metadata.json');
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
meta.medium_generated = true;
meta.substack_generated = true;
meta.bluesky_generated = true;
writeJSON(metaPath, meta);
What the diff looks like after the run
content/2026/10/21/content-automation/bluesky_en.json (excerpt):
[
{
"type": "text",
"text": "When the daily cron stopped waking up, my whole payment‑receipt pipeline went to sleep.\n\nI was staring at a Slack channel full of “receipt not generated” alerts..."
},
{
"type": "tags",
"tags": ["automation", "cron", "payments"]
}
]
content/2026/10/21/content-automation/metadata.json (before → after):
diff
- "medium
---
*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-22*
\#playadev #buildinpublic
Top comments (0)