Automating Multi‑Platform Content Publishing with a Node.js Generator
TL;DR: I added a small Node.js generator that creates platform‑specific markdown/JSON files (Medium, Substack, Bluesky) from a single source and updates metadata.json automatically. The change eliminates manual copy‑paste, reduces human error, and makes the weekly “build‑in‑public” workflow reproducible.
The Problem
Every week I write a single article that needs to be posted on Medium, Substack, and Bluesky. The original workflow looked like this:
- Write the article in a local
content/YYYY/MM/DD/<topic>/article.md. - Copy‑paste the body into three different editors (Medium UI, Substack UI, Bluesky API client).
- Manually edit front‑matter for each platform (titles, tags, language).
- Open
metadata.jsonand flip the*_generatedflags.
The pain points were obvious:
- Human error – a missing tag or a stray markdown element broke the post on one platform.
- Time waste – 30‑45 minutes of repetitive copy‑pasting per article.
-
Inconsistent state –
metadata.jsonoften fell out of sync, causing CI pipelines that check the flag to fail.
The error that finally forced a change was a CI failure that printed:
Error: Expected metadata.generated to be true for platform "medium", but got false.
The CI job halted the release pipeline for the whole week.
What I Tried First
My first attempt was a bash script that used sed to replace placeholders and cp to duplicate the source file:
#!/bin/bash
SRC="content/2026/10/24/albaida-tickets/article.md"
cp $SRC content/2026/10/25/content-automation/medium_en.md
sed -i 's/{{title}}/My Title EN/g' content/2026/10/25/content-automation/medium_en.md
# repeat for substack and bluesky …
What went wrong
-
Platform‑specific quirks – Bluesky expects a JSON payload, not markdown, so
sedcould not generate the correct structure. -
Cross‑platform portability –
sed -ibehaves differently on macOS vs. Linux, breaking CI on the runner. -
No atomicity – If the script crashed after copying the Medium file but before updating
metadata.json, the repo was left in a half‑generated state.
The script was fragile, hard to extend, and produced the same CI error as before.
The Implementation
I rewrote the generator in Node.js (v20), using native fs/promises and a tiny helper library gray-matter to parse front‑matter. The script lives in scripts/generate-content.js and is invoked by an npm task npm run generate:content.
Architecture Decisions
| Decision | Reason |
|---|---|
Single source markdown (source.md) |
Guarantees a single truth source; all platforms derive from it. |
Platform adapters (adapters/*.js) |
Isolate platform‑specific formatting (JSON vs. markdown). |
Atomic write (fs.writeFile with temp file + rename) |
Prevent half‑generated states on crash. |
Metadata sync (metadata.json update in same transaction) |
Guarantees the *_generated flags always reflect reality. |
TypeScript (.ts files) |
Early catching of shape mismatches between adapters. |
Below is a trimmed version of the generator and the adapters that produced the files shown in the diff.
scripts/generate-content.ts
import { readFile, writeFile, rename, mkdir } from 'fs/promises';
import matter from 'gray-matter';
import path from 'path';
import { generateMedium } from '../adapters/medium';
import { generateSubstack } from '../adapters/substack';
import { generateBluesky } from '../adapters/bluesky';
import { updateMetadata } from '../utils/metadata';
async function main() {
const srcPath = path.resolve('content/2026/10/24/albaida-tickets/article.md');
const raw = await readFile(srcPath, 'utf-8');
const { data: front, content } = matter(raw);
// Destination folder (date‑based)
const destDir = path.resolve('content/2026/10/25/content-automation');
await mkdir(destDir, { recursive: true });
// 1️⃣ Medium (EN & ES)
await writeAtomic(
path.join(destDir, 'medium_en.md'),
generateMedium(content, front, 'en')
);
await writeAtomic(
path.join(destDir, 'medium_es.md'),
generateMedium(content, front, 'es')
);
// 2️⃣ Substack (EN & ES)
await writeAtomic(
path.join(destDir, 'substack_en.md'),
generateSubstack(content, front, 'en')
);
await writeAtomic(
path.join(destDir, 'substack_es.md'),
generateSubstack(content, front, 'es')
);
// 3️⃣ Bluesky (EN & ES) – JSON payloads
await writeAtomic(
path.join(destDir, 'bluesky_en.json'),
JSON.stringify(generateBluesky(content, front, 'en'), null, 2)
);
await writeAtomic(
path.join(destDir, 'bluesky_es.json'),
JSON.stringify(generateBluesky(content, front, 'es'), null, 2)
);
// 4️⃣ Changelog entry
const changelogPath = path.join(destDir, 'changelog.md');
const changelogEntry = `## [${new Date().toISOString().split('T')[0]}] content-automation
### Added
- Generated platform files for Albaida tickets series
`;
await writeAtomic(changelogPath, changelogEntry);
// 5️⃣ Update metadata atomically
await updateMetadata({
medium_generated: true,
substack_generated: true,
bluesky_generated: true,
});
}
async function writeAtomic(target: string, data: string) {
const tmp = `${target}.tmp`;
await writeFile(tmp, data, 'utf-8');
await rename(tmp, target);
}
main().catch(err => {
console.error('Generation failed:', err);
process.exit(1);
});
Platform adapters (excerpt)
adapters/medium.ts
export function generateMedium(body: string, front: any, locale: 'en' | 'es'): string {
const title = locale === 'en' ? front.title_en : front.title_es;
const tags = locale === 'en' ? front.tags_en : front.tags_es;
const header = `---\ntitle: "${title}"\ntags: ${JSON.stringify(tags)}\n---\n\n`;
return header + body;
}
adapters/substack.ts
export function generateSubstack(body: string, front: any, locale: 'en' | 'es'): string {
// Substack expects a short intro then the body.
const intro = locale === 'en' ? front.intro_en : front.intro_es;
return `${intro}\n\n${body}`;
}
adapters/bluesky.ts
ts
export interface BlueskyPayload {
type: string;
text: string;
langs?: string[];
}
export function generateBluesky(body: string, front: any, locale: 'en' | 'es'): BlueskyPayload[] {
const text = `${front.title_${locale}} – ${body.slice(0, 200)}…`;
return [
---
*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-26*
\#playadev #buildinpublic
Top comments (0)