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
- 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.writeFilewith temp file + rename) - Prevent half-generated states on crash. - Metadata sync (
metadata.jsonupdate in same transaction) - Guarantees the*_generatedflags always reflect reality. - TypeScript (
.tsfiles) - 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:
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 series - sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-10-26
#playadev #buildinpublic
Comments
No comments yet. Start the discussion.