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:
- Missing fields – The
metadata.jsonflagmedium_generatedwas stillfalseeven after we had generated the Medium version, causing the CI step that publishes to Medium to skip the file. - 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 –
ROOTis calculated once, avoiding hard‑coded folder names. - Platform map – Adding a new platform is as easy as extending the
PLATFORMSobject. - 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