DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Blog Publishing with a JSON‑Driven Content Pipeline

Automating Multi‑Platform Blog Publishing with a JSON‑Driven Content Pipeline

TL;DR: I built a tiny Node.js pipeline that reads a single metadata.json file and spits out ready‑to‑publish Markdown for Medium/Substack and JSON payloads for Bluesky. The latest commit fixes a truncation bug in the Bluesky JSON and adds a changelog entry, making the whole flow reliable again.


The Problem

I was publishing the same article to Medium, Substack, and Bluesky every week. The workflow looked like this:

  1. Write the article in my editor.
  2. Manually copy‑paste the content into each platform’s UI.
  3. Manually edit the front‑matter (title, tags, author) for each site.

Two concrete pain points emerged:

  • Metadata drift – a typo in a tag on one platform would never be caught on the others.
  • Bluesky JSON breakage – the API rejected the payload with Error: post text exceeds 256 characters or, worse, truncated the text mid‑character, producing malformed UTF‑8 sequences. The offending file was content/2026/11/09/content-automation/bluesky_en.json.

The result was wasted time, inconsistent posts, and occasional API errors that only showed up after I hit “Publish”.


What I Tried First

My first attempt was a set of ad‑hoc scripts (publish-medium.sh, publish-substack.sh, publish-bluesky.sh) that each:

  • Read a plain‑text file (article.txt).
  • Append a hard‑coded front‑matter block.
  • Write the result to a platform‑specific directory.

The Bluesky script used a naïve slice:

# publish-bluesky.sh (initial version)
CONTENT=$(cat article.txt)
# Bluesky limits text to 256 characters
POST_TEXT=${CONTENT:0:256}
echo "{\"type\":\"post\",\"text\":\"$POST_TEXT\"}" > bluesky.json
Enter fullscreen mode Exit fullscreen mode

Problems:

  • No single source of truth – every script duplicated the same metadata.
  • Encoding bugs – slicing a UTF‑8 string could cut a multibyte character, corrupting the JSON.
  • No validation – the scripts never checked the final payload against the API schema.

When the truncation bug hit, the generated JSON looked like:

{
  "type": "post",
  "text": "Finally fixed the bug in content/2026/11/09/content-automation/bluesky_en.json that was breaking the auto‑generated Bluesky posts. The d"
}
Enter fullscreen mode Exit fullscreen mode

Notice the dangling \u escape that broke the request.


The Implementation

Architecture Overview

repo/
├─ content/
│   └─ 2026/11/10/content-automation/
│       ├─ metadata.json          # single source of truth
│       ├─ medium_en.md           # generated
│       ├─ substack_en.md         # generated
│       └─ bluesky_en.json        # generated
└─ src/
    └─ publish.js                # core pipeline
Enter fullscreen mode Exit fullscreen mode

The pipeline lives in src/publish.js. It:

  1. Loads metadata.json.
  2. Generates Markdown for Medium and Substack using a shared template.
  3. Generates a validated JSON payload for Bluesky, respecting the 256‑character limit and UTF‑8 boundaries.
  4. Writes the files into the content/... folder.

All steps run in a single npm run generate command, which is now part of our CI (skip ci flag omitted for clarity).

metadata.json – before vs. after

@@ -12,8 +12,8 @@
   "pull_requests": 0,
   "releases": 0,
   "closed_issues": 0,
-  "medium_generated": false,
-  "substack_generated": false,
+  "medium_generated": true,
+  "substack_generated": true,
   "bluesky_generated": false,
   "last_generated": "2026-11-10T03:12:00Z"
Enter fullscreen mode Exit fullscreen mode

The boolean flags let the pipeline know which artifacts have already been produced, preventing unnecessary overwrites.

Core Script (src/publish.js)


js
// src/publish.js
const fs = require('fs');
const path = require('path');

const META_PATH = path.join(__dirname, '..', 'content', '2026', '11', '10', 'content-automation', 'metadata.json');
const OUT_DIR = path.dirname(META_PATH);

// Load metadata
const meta = JSON.parse(fs.readFileSync(META_PATH, 'utf8'));

// Helper: safe UTF‑8 truncation
function truncateUnicode(str, maxBytes) {
  let bytes = 0;
  let i = 0;
  for (; i < str.length; i++) {
    const code = str.charCodeAt(i);
    bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3;
    if (bytes > maxBytes) break;
  }
  return str.slice(0, i);
}

// 1️⃣ Medium Markdown
function generateMedium() {
  const frontMatter = `---
title: "${meta.title}"
tags: ${JSON.stringify(meta.tags)}
date: "${meta.date}"
---\n\n`;
  const body = meta.body; // already markdown
  const content = frontMatter + body;
  const outPath = path.join(OUT_DIR, 'medium_en.md');
  fs.writeFileSync(outPath, content);
  console.log('✔ Medium markdown generated');
}

// 2️⃣ Substack Markdown (same as Medium but with a header)
function generateSubstack() {
  const header =

---

*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-11-11*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)