Automating Weekly Multi‑Channel Drafts with a Node‑JS Content Generator
TL;DR: I built a tiny Node script that pulls data from ClickUp, renders markdown templates, and drops ready‑to‑publish drafts into content/…/medium_*.md and substack_*.md. The change lets the repo self‑generate a weekly newsletter and platform‑specific articles on every push, cutting manual copy‑paste to zero.
The Problem
Every Monday I had to copy the same set of metrics (alerts, deadline breaches, Chromecast disconnect counts) into three separate markdown files:
-
content/…/medium_en.md– for Medium -
content/…/substack_en.md– for Substack -
content/…/weekly/…_newsletter_en.md– for the email list
The process was error‑prone (typos, missing sections) and time‑consuming (≈30 min per week). Moreover, the metadata.json flag that tells the CI pipeline whether a draft had been generated was being edited by hand, leading to false positives ("medium_generated": false stayed false even after the file existed). The symptom was a CI job that always reported “nothing to publish” even though the drafts were on disk.
What I Tried First
My first attempt was a Bash one‑liner that concatenated a static header with a CSV export from ClickUp:
echo "# Weekly Report" > content/weekly/2026-09-27_newsletter_en.md
cat alerts.csv >> content/weekly/2026-09-27_newsletter_en.md
It worked for the newsletter but failed for Medium and Substack because those platforms need front‑matter (YAML) and different section ordering. I tried to duplicate the script three times, each with slight tweaks, but quickly ran into:
- Duplication – any change to the layout required editing three scripts.
- Encoding bugs – the CSV contained UTF‑8 emojis that Bash mangled, causing broken characters in the generated markdown.
At this point the repo was more fragile than the manual copy‑paste.
The Implementation
1. Project layout
repo/
├─ scripts/
│ └─ generate-content.js # <‑ new entry point
├─ templates/
│ ├─ medium_en.hbs # Handlebars template
│ ├─ substack_en.hbs
│ └─ newsletter_en.hbs
├─ content/
│ └─ 2026/10/03/content-automation/
│ ├─ medium_en.md # ← generated
│ ├─ substack_en.md # ← generated
│ └─ metadata.json
└─ .github/workflows/
└─ content-generator.yml
2. Core script (scripts/generate-content.js)
// scripts/generate-content.js
import fs from 'fs/promises';
import path from 'path';
import Handlebars from 'handlebars';
import fetch from 'node-fetch';
// --------------------------------------------------
// 1️⃣ Load data from ClickUp (alerts, deadline breaches)
async function loadMetrics() {
const resp = await fetch('https://api.clickup.com/api/v2/team/12345/goal');
if (!resp.ok) throw new Error(`ClickUp API error: ${resp.status}`);
const { goals } = await resp.json();
return goals.map(g => ({
title: g.name,
count: g.metrics[0].value,
description: g.description,
}));
}
// --------------------------------------------------
// 2️⃣ Compile a Handlebars template
async function render(templateName, context) {
const tmplPath = path.resolve('templates', `${templateName}.hbs`);
const src = await fs.readFile(tmplPath, 'utf8');
const tmpl = Handlebars.compile(src);
return tmpl(context);
}
// --------------------------------------------------
// 3️⃣ Write output files + update metadata flag
async function writeDraft(outPath, content, metaKey) {
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, content, 'utf8');
// bump metadata flag
const metaPath = path.resolve('content/2026/10/03/content-automation/metadata.json');
const metaRaw = await fs.readFile(metaPath, 'utf8');
const meta = JSON.parse(metaRaw);
meta[metaKey] = true;
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
}
// --------------------------------------------------
// 4️⃣ Orchestrate everything
(async () => {
try {
const metrics = await loadMetrics();
const ctx = { date: new Date().toISOString().slice(0, 10), metrics };
// Medium draft
const medium = await render('medium_en', ctx);
await writeDraft(
'content/2026/10/03/content-automation/medium_en.md',
medium,
'medium_generated'
);
// Substack draft
const substack = await render('substack_en', ctx);
await writeDraft(
'content/2026/10/03/content-automation/substack_en.md',
substack,
'substack_generated'
);
// Newsletter (same context, different template)
const newsletter = await render('newsletter_en', ctx);
await writeDraft(
'content/weekly/2026-09-27_newsletter_en.md',
newsletter,
'newsletter_generated'
);
console.log('✅ All drafts generated');
} catch (e) {
console.error('❌ Generation failed:', e);
process.exit(1);
}
})();
Why Handlebars?
I needed a templating engine that could:
- Preserve markdown formatting without escaping (
{{{body}}}) - Allow conditional sections (
{{#if alerts}}…{{/if}})
Handlebars fits nicely and is only a ~30 KB dependency.
3. Template example (templates/medium_en.hbs)
---
title: "When a deadline turned into a cascade of alerts"
date: "{{date}}"
tags: ["productivity","automation"]
---
When a deadline turned into a cascade of alerts
===================================================
{{#each metrics}}
### {{title}}
- **Count:** {{count}}
- {{description}}
{{/each}}
*Generated automatically by `scripts/generate-content.js`.*
4. CI integration
The GitHub Action content-generator.yml runs on every push to main:
yaml
name: Generate Content
on:
push:
branches: [main]
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: node scripts/generate-content.js
- name: Commit generated files
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "chore(content): auto-generate $(date +%Y-%m-%d) [skip ci
---
*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-04*
\#playadev #buildinpublic
Top comments (0)