DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing with a Node.js Generator

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:

  1. Write the article in a local content/YYYY/MM/DD/<topic>/article.md.
  2. Copy‑paste the body into three different editors (Medium UI, Substack UI, Bluesky API client).
  3. Manually edit front‑matter for each platform (titles, tags, language).
  4. Open metadata.json and flip the *_generated flags.

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.json often 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.
Enter fullscreen mode Exit fullscreen mode

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 …
Enter fullscreen mode Exit fullscreen mode

What went wrong

  • Platform‑specific quirks – Bluesky expects a JSON payload, not markdown, so sed could not generate the correct structure.
  • Cross‑platform portability – sed -i behaves 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);
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)