Automated Multi‑Platform Content Publishing – How I Added a Zero‑Touch Generator for Medium, Substack & Bluesky
TL;DR: I built a small Node.js generator that reads a single metadata.json file and spits out ready‑to‑post Markdown (Medium & Substack) and JSON payloads (Bluesky). The change lives in content/2026/10/05/content-automation/ and eliminates manual copy‑paste, cutting weekly publishing time from 30 min to under 2 min.
The Problem
Every Friday I have to publish the same weekly roundup to three different platforms:
-
Medium – expects a full Markdown article (
medium_en.md,medium_es.md). - Substack – also Markdown but with a different front‑matter format.
-
Bluesky – requires a JSON array of “posts” with a
typeandtextfield.
My repository kept a separate Markdown file for each language and platform, and I manually duplicated the body, tweaked headings, and copied the same JSON snippet for Bluesky. The symptom was obvious: a merge conflict every time I edited the body, and a recurring typo like a missing closing back‑tick that broke the Bluesky API call (Error: Unexpected token '}').
The root cause was no single source of truth for the article content. I was maintaining six files that were 99 % identical, only differing in tiny front‑matter bits.
What I Tried First
My first attempt was to write a tiny Bash script that used sed to replace the language code in the filenames and copy the content over:
#!/bin/bash
cp content/2026/10/04/content-automation/medium_en.md \
content/2026/10/05/content-automation/medium_es.md
sed -i 's/English/Spanish/g' content/2026/10/05/content-automation/medium_es.md
It worked for a single run, but it broke as soon as I needed to add a new section (e.g., a “Resources” list). I had to edit every copy manually again, and the script didn’t generate the Bluesky JSON at all. The approach was brittle, not version‑controlled, and it didn’t scale.
The Implementation
1. Central metadata file
I introduced a single metadata.json that describes the article once:
{
"title": "Weekly Review – Content‑Automation",
"date": "2026-10-05",
"tags": ["productivity", "automation"],
"languages": ["en", "es"],
"platforms": {
"medium": true,
"substack": true,
"bluesky": true
},
"author": "Roberto Luna Osorio"
}
The file lives at content/2026/10/05/content-automation/metadata.json. The diff shows the only change was flipping the generation flags:
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated": true,
Now the generator can decide which outputs to emit.
2. Generator script (scripts/generate.js)
I added a new Node script that runs as part of the CI pipeline (or locally with npm run generate). The core logic is in generateContent():
// scripts/generate.js
const fs = require('fs');
const path = require('path');
function loadMetadata() {
const metaPath = path.join(__dirname, '..', 'content', '2026', '10', '05', 'content-automation', 'metadata.json');
return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
}
function loadTemplate(lang) {
const tmplPath = path.join(__dirname, '..', 'templates', `article_${lang}.md`);
return fs.readFileSync(tmplPath, 'utf8');
}
function generateMarkdown(meta, lang) {
const tmpl = loadTemplate(lang);
return tmpl
.replace(/{{title}}/g, meta.title)
.replace(/{{date}}/g, meta.date)
.replace(/{{author}}/g, meta.author);
}
function generateBluesky(meta, lang) {
const body = generateMarkdown(meta, lang);
return [
{
type: "progress",
text: `Published ${meta.title} – ${lang.toUpperCase()}`
},
{
type: "article",
text: body
}
];
}
function writeOutputs(meta) {
meta.languages.forEach(lang => {
if (meta.platforms.medium) {
const md = generateMarkdown(meta, lang);
const outPath = path.join(__dirname, '..', 'content', '2026', '10', '05', 'content-automation', `medium_${lang}.md`);
fs.writeFileSync(outPath, md);
}
if (meta.platforms.substack) {
const md = generateMarkdown(meta, lang);
const outPath = path.join(__dirname, '..', 'content', '2026', '10', '05', 'content-automation', `substack_${lang}.md`);
fs.writeFileSync(outPath, md);
}
if (meta.platforms.bluesky) {
const json = generateBluesky(meta, lang);
const outPath = path.join(__dirname, '..', 'content', '2026', '10', '05', 'content-automation', `bluesky_${lang}.json`);
fs.writeFileSync(outPath, JSON.stringify(json, null, 2));
}
});
}
// Execution
const meta = loadMetadata();
writeOutputs(meta);
console.log('✅ Content generated for', meta.languages.join(', '));
Why this file matters:
- All platform‑specific files are now generated from a single template (
templates/article_en.md&templates/article_es.md). - Adding a new language only requires a new template file; the script auto‑detects it via
metadata.languages. - The JSON payload for Bluesky follows the exact shape the API expects, eliminating the previous manual copy‑paste that produced syntax errors.
3. Template example (templates/article_en.md)
# {{title}}
*Date:* {{date}}
*Author:* {{author}}
---
## What I built this week
The bulk of my work landed in PR #11 for the **content‑automation** repo...
* **Feature A** – description
* **Bugfix B** – description
---
## What’s next
...
The same file exists for Spanish with the appropriate translations.
4. CI integration
I added a step to the GitHub Actions workflow (.github/workflows/content.yml):
name: Content Generation
on:
push:
paths:
- 'content/**/metadata.json'
- 'templates/**'
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: npm run generate
- name: Commit generated files
run: |
git config user.name "github-actions"
git config user.email "actions@github.com"
git add content/2026/10/05/content-automation/*.md
git add content/2026/10/05/content-automation/*.json
git commit -m "chore(content): auto-generate 2026-10-06 [skip ci]" || echo "No changes"
git push
Now every time I edit metadata.json or any template, the generator runs automatically, commits the fresh Markdown/JSON files, and the downstream publishing scripts (scripts/publish-medium.js, scripts/publish-bluesky.js) pick them up without human intervention.
5. Resulting file tree (relevant part)
content/
└─ 2026/10/05/content-automation/
├
---
*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-06*
\#playadev #buildinpublic
Top comments (0)