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
…
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:
-
Header drift – the changelog header in
VS/changelog.mdwas## 2026‑09‑06 VSin some files and## [2026‑09‑06] VSin others, breaking our automated indexer. -
Missing metadata – the
metadata.jsonfile was updated, but the generateddevtomarkdown 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.
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
It worked for simple token substitution, but quickly fell apart:
-
Line‑ending issues –
sedon macOS vs Linux produced different newline characters, breaking the CI diff. -
Complex structures – The Bluesky JSON payload required nested quoting, which
sedcannot handle reliably. -
Scalability – Adding a new platform meant writing another
sedline, increasing maintenance cost.
After a weekend of debugging, the script produced malformed JSON for Bluesky:
SyntaxError: Unexpected token '}' in JSON at position 102
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/...
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) ..."
}
2. Handlebars Templates
devto.hbs
---
title: "{{title}}"
published_at: "{{date}}T09:00:00Z"
tags: {{json tags}}
canonical_url: "https://dev.to/zaerohell/{{slug}}"
---
{{content}}
bluesky.hbs
{
"title": "{{title}}",
"content": "{{{markdownEscape content}}}",
"tags": {{json tags}},
"date": "{{date}}"
}
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);
});
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}}
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)