DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Channel Content Publishing with a Simple Node Script

Automating Multi‑Channel Content Publishing with a Simple Node Script

TL;DR: I added a lightweight Node / npm‑based generator that creates platform‑specific files (Markdown for Medium/Substack, JSON for Bluesky) from a single source template. The change cuts manual copy‑paste time by ~80 % and keeps publishing metadata in sync.


The Problem

My weekly sprint for the content‑automation project required me to publish the same article on three platforms:

  • Medium – expects a .md file with front‑matter.
  • Substack – also consumes Markdown but with a slightly different header format.
  • Bluesky – requires a JSON payload with an array of “post” objects.

Previously I was manually copying the article body into each format, adjusting headings, and updating a metadata.json file by hand. The symptom was obvious in the commit history: a flood of almost‑identical markdown files and a growing metadata.json drift. The biggest pain point showed up as a CI failure when the metadata.json flag medium_generated was still false after I had already pushed the Medium post.

{
  "medium_generated": false,
  "substack_generated": false,
  ...
}
Enter fullscreen mode Exit fullscreen mode

The mismatch caused the CI pipeline to abort, and I realized the process needed automation.


What I Tried First

My first attempt was to write a Bash one‑liner that sed‑ed the source markdown into the different formats and then jq‑ed the JSON for Bluesky. The script looked like this:

#!/usr/bin/env bash
SRC="content/2026/10/26/content-automation/article.md"

# Medium
sed 's/^# /## /' "$SRC" > "content/$(date +%Y/%m/%d)/content-automation/medium_en.md"

# Substack (same as Medium for now)
cp "content/$(date +%Y/%m/%d)/content-automation/medium_en.md" \
   "content/$(date +%Y/%m/%d)/content-automation/substack_en.md"

# Bluesky JSON
jq -n --arg text "$(cat "$SRC")" '[{type:"progress", text:$text}]' \
   > "content/$(date +%Y/%m/%d)/content-automation/bluesky_en.json"
Enter fullscreen mode Exit fullscreen mode

It worked for a single article, but it quickly broke:

  1. Encoding issues – the cat inside jq mangled Unicode characters.
  2. Hard‑coded paths – every new day required manual directory creation.
  3. No error handling – the script silently overwrote files.

Because of these flaws, the script was discarded after the first failed run.


The Implementation

I rewrote the generator in Node.js, which gave me better string handling, asynchronous file I/O, and the ability to reuse a single source template (article.template.md). The core of the solution lives in scripts/generate-content.js. Below is a trimmed version that shows the essential steps.

File: scripts/generate-content.js

#!/usr/bin/env node
import fs from 'fs/promises';
import path from 'path';
import { format } from 'date-fns';
import { execSync } from 'child_process';

// ---------- Config ----------
const ROOT = path.resolve('content');
const DATE = new Date(); // today
const DATE_PATH = format(DATE, 'yyyy/MM/dd');
const DEST = path.join(ROOT, DATE_PATH, 'content-automation');

// ---------- Helpers ----------
async function ensureDir(dir) {
  await fs.mkdir(dir, { recursive: true });
}

function readTemplate() {
  return fs.readFile(
    path.join('templates', 'article.template.md'),
    'utf8'
  );
}

// ---------- Main ----------
(async () => {
  await ensureDir(DEST);

  const raw = await readTemplate();

  // 1️⃣ Medium (adds front‑matter)
  const medium = `---\ntitle: "Weekly Build‑in‑Public: Content‑Automation Sprint"\ndate: ${format(
    DATE,
    "yyyy-MM-dd"
  )}\n---\n\n${raw}`;
  await fs.writeFile(path.join(DEST, 'medium_en.md'), medium);
  console.log('✅ medium_en.md generated');

  // 2️⃣ Substack (different front‑matter style)
  const substack = `---\nsubject: "Weekly Build‑in‑Public: Content‑Automation Sprint"\n---\n\n${raw}`;
  await fs.writeFile(path.join(DEST, 'substack_en.md'), substack);
  console.log('✅ substack_en.md generated');

  // 3️⃣ Bluesky JSON payload
  const bluesky = [
    {
      type: 'progress',
      text: raw.replace(/\n/g, '\\n'), // escape newlines for JSON string
    },
  ];
  await fs.writeFile(
    path.join(DEST, 'bluesky_en.json'),
    JSON.stringify(bluesky, null, 2)
  );
  console.log('✅ bluesky_en.json generated');

  // 4️⃣ Update metadata.json atomically
  const metaPath = path.join(ROOT, 'metadata.json');
  const metaRaw = await fs.readFile(metaPath, 'utf8');
  const meta = JSON.parse(metaRaw);
  meta.medium_generated = true;
  meta.substack_generated = true;
  meta.bluesky_generated = true;
  await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
  console.log('✅ metadata.json updated');

  // 5️⃣ Optional: auto‑commit the changes (CI‑friendly)
  execSync('git add .', { stdio: 'inherit' });
  execSync(
    `git commit -m "chore(content): auto‑generate ${format(
      DATE,
      'yyyy-MM-dd'
    )}"`,
    { stdio: 'inherit' }
  );
})();
Enter fullscreen mode Exit fullscreen mode

Why this works

Decision Reason
Node.js + ES modules Native async/await, clean import syntax, no external runtime needed on CI.
fs/promises Guarantees non‑blocking I/O, easier error handling with try/catch.
date-fns Small footprint for date formatting; avoids heavy Moment.js.
Atomic metadata.json update Reads the whole file, flips flags, writes back in one step – eliminates race conditions.
Auto‑commit Keeps the repo in sync with CI expectations (medium_generated flag).

Generated Files (excerpt)

content/2026/10/27/content-automation/bluesky_en.json

[
  {
    "type": "progress",
    "text": "I was halfway through a two‑week sprint on **albaida‑tickets** when I realized the dashboard I’d been polishing for days didn’t fit in anyone’s pocket. The grid of cards, the side‑b..."
  }
]
Enter fullscreen mode Exit fullscreen mode

content/2026/10/27/content-automation/medium_en.md (first 5 lines)

---
title: "Weekly Build‑in‑Public: Content‑Automation Sprint"
date: 2026-10-27
---

I was halfway through a two‑week sprint on **albaida‑tickets** when I realized the dashboard I’d been polishing for days didn’t fit in anyone’s pocket...
Enter fullscreen mode Exit fullscreen mode

content/2026/10/27/content-automation/metadata.json (relevant section)

{
  "pull_requests": 0,
  "releases": 0,
  "closed_issues": 0,
  "medium_generated": true,
  "substack_generated": true,
  "bluesky_generated": true
}
Enter fullscreen mode Exit fullscreen mode

All three platform files are now created with a single command:

npm run generate-content   # defined in package.json as "node scripts/generate-content.js"
Enter fullscreen mode Exit fullscreen mode

The CI pipeline now checks only the boolean flags in metadata.json. If any flag is false, the build fails


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-28

#playadev #buildinpublic

Top comments (0)