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
Top comments (0)