Automating Multi‑Platform Content Publishing with a Tiny Node Script
TL;DR: I built a Node / TypeScript script that pulls the latest changelog, renders platform‑specific markdown, updates metadata.json, and commits the files in one go. This removes manual copy‑paste, keeps platform flags in sync, and lets CI skip builds for pure‑content commits.
The Problem
Our weekly sprint ends with a handful of blog‑style updates (Medium, Substack, Bluesky) and a changelog for internal tracking. Previously I was:
- Manually copying the same text into several
*.mdfiles. - Forgetting to flip the
"medium_generated"/"substack_generated"flags inmetadata.json. - Accidentally triggering a full CI pipeline on a commit that only touched content, wasting minutes on a cold Render instance.
The symptom was a skip ci comment added after the fact and a growing backlog of mismatched metadata. The error message from the CI logs was simply “No build steps to run”, but the real pain was the manual overhead and the risk of publishing incomplete or out‑of‑sync posts.
What I Tried First
My first attempt was a shell script that cat‑ed a template file into each platform folder:
#!/bin/bash
cp templates/weekly.md content/$(date +%Y/%m/%d)/VS/medium_en.md
cp templates/weekly.md content/$(date +%Y/%m/%d)/VS/substack_en.md
I added git commit -m "chore(content): auto‑generate $(date)" afterwards.
What went wrong?
- No validation of the target directories – the script crashed when the date‑based folder didn’t exist.
- The
metadata.jsonflag updates were missing, so the downstream publishing bots thought the content wasn’t ready. - The script was not part of the repo, so each team member had a slightly different version, leading to subtle formatting differences.
The Implementation
1. Directory layout
content/
└─ 2026/
└─ 10/
└─ 13/
├─ VS/
│ ├─ changelog.md
│ ├─ medium_en.md
│ ├─ medium_es.md
│ ├─ substack_en.md
│ ├─ substack_es.md
│ ├─ bluesky_es.json
│ └─ metadata.json
└─ content-automation/
└─ … (same structure for other projects)
All platform‑specific files live under the same date folder, making it easy for a single script to discover the target path with path.join.
2. Core script (scripts/generate-content.ts)
import { promises as fs } from 'fs';
import path from 'path';
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
async function ensureDir(dir: string) {
await fs.mkdir(dir, { recursive: true });
}
// Render a simple markdown template with injected variables
function renderTemplate(template: string, data: Record<string, string>) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] ?? '');
}
// -------------------------------------------------------------------
// Main
// -------------------------------------------------------------------
async function main() {
const today = new Date();
const datePath = path.join(
__dirname,
'..',
'content',
`${today.getFullYear()}`,
`${String(today.getMonth() + 1).padStart(2, '0')}`,
`${String(today.getDate()).padStart(2, '0')}`,
'VS'
);
await ensureDir(datePath);
// 1️⃣ Load the raw changelog (generated elsewhere)
const rawChangelog = await fs.readFile(
path.join(datePath, 'changelog.md'),
'utf8'
);
// 2️⃣ Prepare platform‑specific bodies
const platforms = [
{ name: 'medium_en', lang: 'en' },
{ name: 'medium_es', lang: 'es' },
{ name: 'substack_en', lang: 'en' },
{ name: 'substack_es', lang: 'es' },
{ name: 'bluesky_es', lang: 'es' },
];
const template = await fs.readFile(
path.resolve(__dirname, '../templates/weekly.md'),
'utf8'
);
for (const p of platforms) {
const rendered = renderTemplate(template, {
LANG: p.lang,
CHANGELOG: rawChangelog,
});
const ext = p.name.includes('bluesky') ? '.json' : '.md';
const filePath = path.join(datePath, `${p.name}${ext}`);
// 3️⃣ Write the platform file
await fs.writeFile(filePath, rendered, 'utf8');
console.log(`✅ ${p.name}${ext} generated`);
}
// 4️⃣ Update metadata flags
const metaPath = path.join(datePath, 'metadata.json');
const metaRaw = await fs.readFile(metaPath, 'utf8');
const meta = JSON.parse(metaRaw);
meta.medium_generated = true;
meta.substack_generated = true;
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf8');
console.log('✅ metadata.json flags updated');
// 5️⃣ Commit with [skip ci] automatically
const { execSync } = require('child_process');
execSync('git add .', { stdio: 'inherit' });
execSync(
`git commit -m "chore(content): auto-generate ${today
.toISOString()
.slice(0, 10)} [skip ci]"`,
{ stdio: 'inherit' }
);
execSync('git push', { stdio: 'inherit' });
}
main().catch(err => {
console.error('❌ Generation failed', err);
process.exit(1);
});
Key points in the code
| Line | Why it matters |
|---|---|
await fs.mkdir(dir, { recursive: true }) |
Guarantees the date folder exists, fixing the crash from the shell script. |
renderTemplate |
Centralises variable interpolation ({{LANG}}, {{CHANGELOG}}) so we only maintain one source of truth. |
Platform loop (platforms array) |
Adding a new platform is as easy as pushing a new object – no copy‑paste. |
metadata.json update |
Directly toggles "medium_generated" and "substack_generated" to true, keeping publishing bots in sync. |
git commit … [skip ci] |
Automates the CI‑skip flag, removing the manual edit step. |
3. Example of generated files (excerpt)
content/2026/10/13/VS/medium_en.md (first 10 lines)
markdown
I was in the middle of a sprint when two unrelated
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/content-automation` · 2026-10-14*
\#playadev #buildinpublic
Top comments (0)