DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Weekly Multi‑Channel Drafts with a Node‑JS Content Generator

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
})();
Enter fullscreen mode Exit fullscreen mode

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`.*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)