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) 

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 

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, 

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(); 

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" 

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> ); }; 

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" } } 

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