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:
- Write the article in my editor.
- Manually copy‑paste the content into each platform’s UI.
- 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 charactersor, worse, truncated the text mid‑character, producing malformed UTF‑8 sequences. The offending file wascontent/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
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"
}
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
The pipeline lives in src/publish.js. It:
- Loads
metadata.json. - Generates Markdown for Medium and Substack using a shared template.
- Generates a validated JSON payload for Bluesky, respecting the 256‑character limit and UTF‑8 boundaries.
- 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"
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
Top comments (0)