Automating Multi‑Platform Content Publishing with a Typed Generator (Node / TS)
TL;DR: I built a small TypeScript generator that pulls a single source markdown file, enriches it with platform‑specific front‑matter, and writes out the final assets for Dev.to, Medium, Substack, Bluesky and our weekly newsletter. The change eliminated manual copy‑pastes, fixed metadata mismatches, and let us version‑control content alongside code.
The Problem
Our content‑automation repo stored a separate markdown file for every platform and language (e.g., content/2026/09/12/VS/medium_en.md, content/2026/09/12/VS/bluesky_es.json, etc.). Adding a new article meant:
- Opening the source article in Google Docs.
- Exporting to markdown.
- Manually copying the body into each platform‑specific file.
- Adding or editing front‑matter (tags, canonical URL, image).
This workflow produced two concrete bugs that showed up in the last commit series (2026‑09‑13):
-
Missing/incorrect metadata – The
metadata.jsonfile referenced adevto_urlthat didn’t exist, causing our CI script that validates outbound links to fail. -
Duplication errors – When we added the test suite
apps/api/src/__tests__/administradoras.test.ts(188 new tests), the same markdown content was duplicated across platforms, inflating the repo size and making future edits error‑prone.
The symptom in CI was:
ERROR: Missing required field "canonical_url" in content/2026/09/12/VS/medium_en.md front‑matter
ERROR: Duplicate article slug "vs-weekly-review" found in 3 platform files
What I Tried First
My first attempt was a bash one‑liner that used sed to replace placeholders in a template:
#!/usr/bin/env bash
TEMPLATE=templates/article.md
for LANG in en es; do
for PLAT in medium devto substack bluesky; do
cp $TEMPLATE content/2026/09/12/VS/${PLAT}_${LANG}.md
sed -i "s/{{title}}/$TITLE/g" content/2026/09/12/VS/${PLAT}_${LANG}.md
# ... more sed calls for tags, images, etc.
done
done
It worked for a single article, but quickly broke:
-
Platform‑specific front‑matter (e.g., JSON for Bluesky) could not be expressed with simple
sed. - Adding a new language required editing the script, violating the DRY principle.
- No validation – the script happily produced files with missing fields, leading to the CI errors above.
The Implementation
1. Define a Unified Content Schema
I introduced src/schema/content.ts that describes the shape of a content entry:
// src/schema/content.ts
export interface ContentMeta {
slug: string;
title: string;
date: string; // ISO 8601
tags: string[];
canonical_url?: string;
image?: string;
language: 'en' | 'es';
}
export interface PlatformPayload {
frontMatter: Record<string, unknown>;
body: string;
}
A JSON schema file (schema/content.schema.json) is used by ajv for runtime validation.
2. Central Generator
The core generator lives in src/generator.ts. It reads a single source markdown (src/articles/VS.md), parses the front‑matter with gray-matter, and produces platform‑specific payloads.
// src/generator.ts
import matter from 'gray-matter';
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { ContentMeta, PlatformPayload } from './schema/content';
import Ajv from 'ajv';
const ajv = new Ajv();
const schema = JSON.parse(readFileSync('schema/content.schema.json', 'utf8'));
const validate = ajv.compile(schema);
export function generate(slug: string) {
const srcPath = `src/articles/${slug}.md`;
const raw = readFileSync(srcPath, 'utf8');
const { data, content } = matter(raw);
const meta: ContentMeta = {
slug,
title: data.title,
date: data.date,
tags: data.tags,
canonical_url: data.canonical_url,
image: data.image,
language: data.language,
};
if (!validate(meta)) {
console.error('Validation errors:', validate.errors);
process.exit(1);
}
const platforms = ['medium', 'devto', 'substack', 'bluesky', 'newsletter'];
platforms.forEach((platform) => {
const payload = buildPayload(platform, meta, content);
const outDir = `content/2026/09/12/VS/${platform}_${meta.language}`;
mkdirSync(outDir, { recursive: true });
const ext = platform === 'bluesky' ? 'json' : 'md';
writeFileSync(`${outDir}.${ext}`, serialize(payload, platform));
});
}
3. Platform Builders
Each platform has a small builder that maps the generic ContentMeta to the required front‑matter.
// src/platforms/medium.ts
import { ContentMeta, PlatformPayload } from '../schema/content';
export function buildMedium(meta: ContentMeta, body: string): PlatformPayload {
return {
frontMatter: {
title: meta.title,
tags: meta.tags,
canonicalUrl: meta.canonical_url,
publishDate: meta.date,
image: meta.image,
version: 2,
},
body,
};
}
The buildPayload dispatcher in src/generator.ts simply switches on the platform name and calls the appropriate builder.
4. Serialization
Because Bluesky expects JSON, while the rest use markdown with YAML front‑matter, we abstract serialization:
function serialize(payload: PlatformPayload, platform: string): string {
if (platform === 'bluesky') {
return JSON.stringify(payload.frontMatter, null, 2);
}
const yaml = require('js-yaml').dump(payload.frontMatter);
return `---\n${yaml}---\n\n${payload.body}`;
}
5. Adding Tests
The commit that added apps/api/src/__tests__/administradoras.test.ts also introduced a test suite for the generator:
ts
// apps/api/src/__tests__/content-generator.test.ts
import { generate } from '../../../src/generator';
import { existsSync, readFileSync } from 'fs';
import path from 'path';
describe('Content Generator', () => {
const slug = 'VS';
beforeAll(() => generate(slug));
it('creates a Medium EN file with proper front‑matter', () => {
const file = path.join('content/2026/09/12/VS/medium_en.md');
expect(existsSync(file)).toBe
---
*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-09-13*
\#playadev #buildinpublic
Top comments (0)