DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Channel, Multilingual Content Generation with a JSON‑Driven Pipeline

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:

  1. Copy the article body into three editors.
  2. Update the language‑specific headings.
  3. Flip the medium_generated / substack_generated flags in metadata.json by 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.
Enter fullscreen mode Exit fullscreen mode

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

What went wrong

  • The wrapper ran the generator sequentially, doubling the runtime.
  • sed silently failed on Windows line endings, leaving the flags unchanged.
  • Errors from generate.js were 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:

  1. Loads a single source file (src/content/source.yaml) containing the article body, front‑matter, and translations.
  2. Produces platform‑specific outputs by applying a map of renderers (Medium, Substack, Bluesky).
  3. Writes a changelog entry automatically based on the Git commit date.
  4. Updates metadata.json atomically using fs.promises.writeFile with a temporary file and rename.

Core data model (src/content/types.ts)

export interface Article {
  slug: string;
  title: string;
  body: string;
  translations: {
    en: string;
    es: string;
  };
  tags: string[];
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

  1. **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)