DEV Community

Roberto Luna
Roberto Luna

Posted on

Automated Multi‑Platform Content Publishing – How I Added a Zero‑Touch Generator for Medium, Substack & Bluesky

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 type and text field.

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

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

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

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(', '));
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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

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

Top comments (0)