Automating Multi‑Channel, Multilingual Content Generation with a JSON‑Driven Pipeline
TL;DR: I refactored the content‑automation repo to generate platform‑specific Markdown/JSON files (Medium, Substack, Bluesky) from a single source of truth (metadata.json). The change eliminates manual copy‑paste, guarantees language parity, and makes the CI pipeline idempotent.
The Problem
Our weekly publishing workflow required three separate drafts: one for Medium, one for Substack, and a JSON payload for Bluesky. The process was manual:
- Copy the article body into three editors.
- Update the language‑specific headings.
- Flip the
medium_generated/substack_generatedflags inmetadata.jsonby hand.
The biggest pain point was the invisible bottleneck that appeared every Friday. A missing changelog.md entry or an out‑of‑sync flag caused the CI job to abort with:
ERROR: metadata.json indicates medium_generated = false but ./content/.../medium_en.md is present.
Because the script that creates the drafts was a single monolithic Node file, any failure left the repository in a half‑generated state, forcing a manual rollback.
What I Tried First
My first attempt was to add a simple Bash wrapper that called the existing generate.js script twice—once for English and once for Spanish—then touch the flag fields with sed. The wrapper looked like this:
#!/usr/bin/env bash
node generate.js --lang en && sed -i 's/"medium_generated": false/"medium_generated": true/' metadata.json
node generate.js --lang es && sed -i 's/"substack_generated": false/"substack_generated": true/' metadata.json
What went wrong
- The wrapper ran the generator sequentially, doubling the runtime.
-
sedsilently failed on Windows line endings, leaving the flags unchanged. - Errors from
generate.jswere swallowed, so the CI job still succeeded with partially generated content.
The Implementation
I rewrote the pipeline as a pure functional TypeScript module (src/content/renderer.ts) that:
-
Loads a single source file (
src/content/source.yaml) containing the article body, front‑matter, and translations. - Produces platform‑specific outputs by applying a map of renderers (Medium, Substack, Bluesky).
- Writes a changelog entry automatically based on the Git commit date.
-
Updates
metadata.jsonatomically usingfs.promises.writeFilewith a temporary file andrename.
Core data model (src/content/types.ts)
export interface Article {
slug: string;
title: string;
body: string;
translations: {
en: string;
es: string;
};
tags: string[];
}
Renderer registry (src/content/renderers.ts)
import { Article } from './types';
import { renderMedium } from './medium';
import { renderSubstack } from './substack';
import { renderBluesky } from './bluesky';
export const renderers = {
medium: renderMedium,
substack: renderSubstack,
bluesky: renderBluesky,
} as const;
export type Platform = keyof typeof renderers;
Main pipeline (src/content/renderer.ts)
import { readFile, writeFile, rename } from 'fs/promises';
import path from 'path';
import { Article } from './types';
import { renderers, Platform } from './renderers';
async function loadArticle(): Promise<Article> {
const raw = await readFile('src/content/source.yaml', 'utf8');
// yaml parsing omitted for brevity
return parseYaml(raw) as Article;
}
async function writePlatformFiles(article: Article) {
for (const platform of Object.keys(renderers) as Platform[]) {
const { filename, content } = renderers[platform](article);
const outPath = path.join('content', '2026', '10', '31', 'content-automation', filename);
await writeFile(outPath, content);
}
}
async function updateMetadata() {
const metaPath = 'content/2026/10/31/content-automation/metadata.json';
const metaRaw = await readFile(metaPath, 'utf8');
const meta = JSON.parse(metaRaw);
meta.medium_generated = true;
meta.substack_generated = true;
const tmp = `${metaPath}.tmp`;
await writeFile(tmp, JSON.stringify(meta, null, 2));
await rename(tmp, metaPath);
}
async function generate() {
const article = await loadArticle();
await writePlatformFiles(article);
await updateMetadata();
console.log('✅ All platform files generated.');
}
generate().catch(err => {
console.error('❌ Generation failed:', err);
process.exit(1);
});
Platform‑specific renderers
Medium (English) – src/content/medium.ts
import { Article } from './types';
export function renderMedium(article: Article) {
const filename = `${article.slug}/medium_en.md`;
const frontMatter = `---\ntitle: "${article.title}"\ntags: ${article.tags.join(', ')}\n---\n\n`;
const content = `${frontMatter}${article.translations.en}\n`;
return { filename, content };
}
Substack (Spanish) – src/content/substack.ts
import { Article } from './types';
export function renderSubstack(article: Article) {
const filename = `${article.slug}/substack_es.md`;
const header = `**Subject:** ${article.title} (ES)\n\n---\n\n`;
const content = `${header}${article.translations.es}\n`;
return { filename, content };
}
Bluesky (Spanish JSON payload) – src/content/bluesky.ts
import { Article } from './types';
export function renderBluesky(article: Article) {
const filename = `${article.slug}/bluesky_es.json`;
const payload = [
{
type: 'avance',
text: article.translations.es.slice(0, 140) + '…',
},
];
return { filename, content: JSON.stringify(payload, null, 2) };
}
Resulting file tree (excerpt)
content/2026/10/31/content-automation/
├─ medium_en.md // generated by renderMedium()
├─ substack_es.md // generated by renderSubstack()
├─ bluesky_es.json // generated by renderBluesky()
├─ metadata.json // flags updated atomically
└─ changelog.md // auto‑appended by CI step
The changelog.md entry is now created by a tiny Git hook (scripts/changelog.ts) that reads the latest commit hash and injects a markdown block:
const entry = `## [${date}] content‑automation
### Added
- ${generatedFiles.join('\n- ')}
`;
await appendFile('content/2026/10/31/content-automation/changelog.md', entry);
All files are committed in a single CI job, guaranteeing that the repository never lands in a partially generated state.
Key Takeaway
Treat your publishing pipeline as pure data transformation: keep a single source of truth (YAML/JSON) and let deterministic renderers produce every downstream artifact. By making the generation step idempotent and atomic, you eliminate hidden bottlenecks and make CI failures deterministic.
What's Next
- **Add a GraphQL
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-11-01
#playadev #buildinpublic
Top comments (0)