DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing with a Date‑Driven Markdown Generator

Automating Multi‑Platform Content Publishing with a Date‑Driven Markdown Generator

TL;DR: I built a Node.js generator that reads a single source of truth (JSON + Markdown templates) and emits platform‑specific Markdown files with correct headers, front‑matter, and token‑aware changelogs. The change eliminates manual copy‑paste errors and keeps every channel (Dev.to, Medium, Substack, Bluesky) in sync.


The Problem

Our weekly newsletter pipeline lives in the content-automation repo. Every Monday we need to push the same article to four platforms, each with its own markdown quirks:

  • Dev.to expects a front‑matter block (title, tags, published_at).
  • Medium uses a simple markdown body but no front‑matter.
  • Substack needs a “Subject:” line at the top.
  • Bluesky consumes a JSON payload (title, content, tags).

Previously we kept a copy of the article in each folder:

content/2026/09/06/VS/medium_en.md
content/2026/09/06/VS/substack_en.md
content/2026/09/06/VS/bluesky_en.json
…
Enter fullscreen mode Exit fullscreen mode

When a change was required (e.g., fixing a typo or updating the changelog header), we edited every file manually. This caused two concrete bugs:

  1. Header drift – the changelog header in VS/changelog.md was ## 2026‑09‑06 VS in some files and ## [2026‑09‑06] VS in others, breaking our automated indexer.
  2. Missing metadata – the metadata.json file was updated, but the generated devto markdown still pointed to the old URL, leading to broken links on the platform.

The symptom showed up in CI as a diff mismatch:

ERROR: content/2026/09/06/VS/changelog.md is out of sync with source template.
Enter fullscreen mode Exit fullscreen mode

What I Tried First

My first attempt was a quick shell script that used sed to replace placeholders:

#!/usr/bin/env bash
DATE=$(date +%Y-%m-%d)
sed "s/{{DATE}}/$DATE/g" templates/medium.md > content/$DATE/VS/medium_en.md
Enter fullscreen mode Exit fullscreen mode

It worked for simple token substitution, but quickly fell apart:

  • Line‑ending issuessed on macOS vs Linux produced different newline characters, breaking the CI diff.
  • Complex structures – The Bluesky JSON payload required nested quoting, which sed cannot handle reliably.
  • Scalability – Adding a new platform meant writing another sed line, increasing maintenance cost.

After a weekend of debugging, the script produced malformed JSON for Bluesky:

SyntaxError: Unexpected token '}' in JSON at position 102
Enter fullscreen mode Exit fullscreen mode

The Implementation

I rewrote the generator in Node.js (v20) using Handlebars for templating and a tiny orchestration layer. The architecture looks like this:

content-automation/
├─ src/
│  ├─ generate.js          # entry point
│  ├─ templates/
│  │  ├─ devto.hbs
│  │  ├─ medium.hbs
│  │  ├─ substack.hbs
│  │  └─ bluesky.hbs
│  └─ utils/
│     └─ date.js
├─ data/
│  └─ 2026-09-06.json      # source of truth for the article
└─ content/
   └─ 2026/09/06/VS/...
Enter fullscreen mode Exit fullscreen mode

1. Source‑of‑Truth JSON

data/2026-09-06.json holds everything needed for the week:

{
  "title": "Migrating 30 Pages from Inline Styles to Design Tokens",
  "slug": "inline-styles-to-design-tokens",
  "date": "2026-09-06",
  "tags": ["nextjs", "design-tokens", "refactor"],
  "author": "Roberto Luna Osorio",
  "summary": "How we replaced inline CSS with centralized tokens in a monorepo.",
  "content": "## Introduction\n\n... (markdown body) ..."
}
Enter fullscreen mode Exit fullscreen mode

2. Handlebars Templates

devto.hbs

---
title: "{{title}}"
published_at: "{{date}}T09:00:00Z"
tags: {{json tags}}
canonical_url: "https://dev.to/zaerohell/{{slug}}"
---

{{content}}
Enter fullscreen mode Exit fullscreen mode

bluesky.hbs

{
  "title": "{{title}}",
  "content": "{{{markdownEscape content}}}",
  "tags": {{json tags}},
  "date": "{{date}}"
}
Enter fullscreen mode Exit fullscreen mode

Notice the custom helpers json (pretty‑prints arrays) and markdownEscape (escapes newlines for JSON strings).

3. Generator Logic (src/generate.js)

import fs from 'fs';
import path from 'path';
import Handlebars from 'handlebars';
import { format } from 'date-fns';
import { markdownEscape } from './utils/markdown.js';

// Register helpers
Handlebars.registerHelper('json', (context) => JSON.stringify(context));
Handlebars.registerHelper('markdownEscape', markdownEscape);

const DATA_DIR = path.resolve('data');
const TEMPLATE_DIR = path.resolve('src/templates');
const OUT_ROOT = path.resolve('content');

async function loadJSON(date) {
  const file = path.join(DATA_DIR, `${date}.json`);
  return JSON.parse(await fs.promises.readFile(file, 'utf8'));
}

async function render(templateName, data) {
  const tplPath = path.join(TEMPLATE_DIR, `${templateName}.hbs`);
  const source = await fs.promises.readFile(tplPath, 'utf8');
  const template = Handlebars.compile(source);
  return template(data);
}

async function writeFile(outPath, content) {
  await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
  await fs.promises.writeFile(outPath, content, 'utf8');
}

async function main() {
  const date = process.argv[2] ?? format(new Date(), 'yyyy-MM-dd');
  const data = await loadJSON(date);
  const platforms = ['devto', 'medium', 'substack', 'bluesky'];

  for (const platform of platforms) {
    const rendered = await render(platform, data);
    const ext = platform === 'bluesky' ? 'json' : 'md';
    const outPath = path.join(
      OUT_ROOT,
      date.replace(/-/g, '/'),
      'VS',
      `${platform}.${ext}`
    );
    await writeFile(outPath, rendered);
    console.log(`✅ ${platform} generated → ${outPath}`);
  }
}

main().catch((err) => {
  console.error('❌ Generation failed:', err);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

4. Updating the Changelog Header

The original problem with the header drift was solved by moving the header generation into the same pipeline. src/templates/changelog.hbs:

## [{{date}}] VS

### Added
{{#each added}}
- **{{this}}**
{{/each}}

### Fixed
{{#each fixed}}
- {{this}}
{{/each}}
Enter fullscreen mode Exit fullscreen mode

The data/2026-09-06.json now contains an


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-09-07

#playadev #buildinpublic

Top comments (0)