DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing with a Typed Generator (Node / TS)

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:

  1. Opening the source article in Google Docs.
  2. Exporting to markdown.
  3. Manually copying the body into each platform‑specific file.
  4. 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.json file referenced a devto_url that 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

Top comments (0)