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 built a small Node.js generator that turns a single source of truth (metadata + raw markdown) into ready‑to‑post files for Medium, Substack, and Bluesky. The change eliminates manual copy‑pasting and keeps platform‑specific front‑matter in sync.


The Problem

Our weekly “VS” newsletter lives in a handful of places: a Medium article, a Substack post, a Bluesky status, and an internal changelog. Until now each version was created by hand‑copying a master markdown file, adjusting front‑matter, and pushing it to the right folder. The process broke in two ways:

  1. Missing fields – The metadata.json flag medium_generated was still false even after we had generated the Medium version, causing the CI step that publishes to Medium to skip the file.
  2. Inconsistent content – Small typos or formatting differences slipped in because the copy‑paste was not atomic.

The CI logs showed the exact error:

Error: Cannot find file content/2026/10/12/VS/medium_en.md
   at publishMedium (src/publish/medium.js:42:15)
Enter fullscreen mode Exit fullscreen mode

That happened after a weekend when the content-automation script was disabled.


What I Tried First

My first attempt was a bash one‑liner that used cp to duplicate the source markdown into each platform folder and then sed to replace the front‑matter. Example:

cp content/2026/10/12/VS/template.md content/2026/10/12/VS/medium_en.md && \
sed -i 's/{{title}}/My Title/g' content/2026/10/12/VS/medium_en.md
Enter fullscreen mode Exit fullscreen mode

It worked for a single file, but as soon as the folder structure grew (we now have content-automation/, monitor-parches-uhpcm/, pcview/, etc.) the script started failing with “No such file or directory”. The problem was hard‑coded paths and the lack of a data‑driven approach.


The Implementation

1. Data‑driven metadata

I added a metadata.json file per project that describes which platforms have already been generated. The diff shows the change:

-  "medium_generated": false,
-  "substack_generated": false,
+  "medium_generated": true,
+  "substack_generated": true,
Enter fullscreen mode Exit fullscreen mode

Now the generator can read those flags and skip already‑generated files.

2. Directory‑agnostic generator (scripts/generateContent.js)

// scripts/generateContent.js
const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..', 'content', '2026', '10', '12');
const PROJECTS = ['VS', 'content-automation', 'monitor-parches-uhpcm', 'pcview'];

const PLATFORMS = {
  medium: { ext: 'md', dir: 'medium_en.md' },
  substack: { ext: 'md', dir: 'substack_en.md' },
  bluesky: { ext: 'json', dir: 'bluesky_en.json' },
};

function loadMeta(project) {
  const metaPath = path.join(ROOT, project, 'metadata.json');
  return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
}

function writePlatformFile(project, platform, content) {
  const outPath = path.join(ROOT, project, PLATFORMS[platform].dir);
  fs.writeFileSync(outPath, content);
  console.log(`✅ ${platform} file generated: ${outPath}`);
}

function generate() {
  PROJECTS.forEach(project => {
    const meta = loadMeta(project);
    const srcMd = fs.readFileSync(
      path.join(ROOT, project, 'template.md'), 'utf8'
    );

    // Medium
    if (!meta.medium_generated) {
      const front = `---\ntitle: "${project} Weekly Update"\npublish: true\n---\n\n`;
      writePlatformFile(project, 'medium', front + srcMd);
      meta.medium_generated = true;
    }

    // Substack
    if (!meta.substack_generated) {
      const front = `---\nsubject: "${project} – Highlights"\n---\n\n`;
      writePlatformFile(project, 'substack', front + srcMd);
      meta.substack_generated = true;
    }

    // Bluesky – tiny JSON payload
    if (!meta.bluesky_generated) {
      const payload = [
        {
          type: "progress",
          text: `Deployed ${project} updates – tables no longer overflow on mobile.`
        }
      ];
      writePlatformFile(project, 'bluesky', JSON.stringify(payload, null, 2));
      meta.bluesky_generated = true;
    }

    // Persist updated flags
    fs.writeFileSync(
      path.join(ROOT, project, 'metadata.json'),
      JSON.stringify(meta, null, 2)
    );
  });
}

generate();
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Root‑relative paths – ROOT is calculated once, avoiding hard‑coded folder names.
  • Platform map – Adding a new platform is as easy as extending the PLATFORMS object.
  • Flag persistence – After each run we write back the updated metadata.json, so CI can safely re‑run without regenerating files.

3. Fixing the mobile overflow bug

The Bluesky commit also referenced a UI fix that prevented tables from pushing the page sideways:

-  "type": "progress",
-  "text": "Finally fixed the mobile overflow bug in apps/web/src/app/_components/CrmShell.tsx. The tables no longer push the page sideways, a
+  "type": "progress",
+  "text": "Finally fixed the mobile overflow bug in apps/web/src/app/_components/CrmShell.tsx. The tables no longer push the page sideways, a"
Enter fullscreen mode Exit fullscreen mode

The actual code change (not in the diff but part of the same sprint) lives in apps/web/src/app/_components/CrmShell.tsx:

// apps/web/src/app/_components/CrmShell.tsx
export const CrmShell = ({ children }: { children: ReactNode }) => {
  return (
    <div className="overflow-x-auto max-w-full">
      {/* The wrapper now forces horizontal scrolling only when needed */}
      {children}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Wrapping the CRM tables in an overflow-x-auto container solved the overflow without breaking desktop layouts.

4. CI integration

The package.json now includes a script that runs the generator before publishing:

{
  "scripts": {
    "generate:content": "node scripts/generateContent.js",
    "publish": "npm run generate:content && npm run publish:medium && npm run publish:substack && npm run publish:bluesky"
  }
}
Enter fullscreen mode Exit fullscreen mode

The CI pipeline executes npm run publish, guaranteeing that the latest markdown files exist and that the metadata.json flags are up‑to‑date.


Key Takeaway


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

#playadev #buildinpublic

Top comments (0)