DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing with a Single Source of Truth

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

What went wrong

  • Missing fields – Bluesky expects an array of objects with type and text. 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'"}
Enter fullscreen mode Exit fullscreen mode

The Implementation

I rewrote the generator as a single source of truth pipeline:

  1. Keep only one markdown per language (medium_en.md, medium_es.md).
  2. Parse front‑matter (title, tags) using gray-matter.
  3. 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).
  4. Update metadata.json atomically 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

Top comments (0)