DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Weekly Content Publishing with a Node Script: Fixing CI Breakage and Adding Multi‑Platform Output

Automating Weekly Content Publishing with a Node Script: Fixing CI Breakage and Adding Multi‑Platform Output

TL;DR: I extended my content‑automation pipeline to generate Markdown, JSON and HTML for Medium, Substack and Bluesky in a single run, and I hardened the CI step that kept failing because of stray line‑breaks. The result is a reproducible, version‑controlled weekly post that lands on three platforms automatically.


The Problem

My weekly “what‑I‑did‑this‑week” note lives in content/2026/10/10/content-automation/medium_en.md. The script that builds the post (scripts/generate‑weekly.ts) writes the file, commits it, and triggers the CI pipeline that publishes to Medium, Substack and Bluesky.

Two issues kept surfacing:

  1. CI failures – The CI job would abort with error: line 1: unexpected EOF while looking for matching \'because the generated Markdown sometimes contained stray carriage returns (\r\n`) from Windows‑style line endings.
  2. Missing platform payloads – I only had a Markdown file for Medium/Substack. Bluesky required a JSON payload (bluesky_es.json), but I was manually copying the content, which was error‑prone and not versioned.

The symptom was a broken pipeline every other week and a growing backlog of manual copy‑pastes.

What I Tried First

My first attempt was to add a quick sed command in the CI script to strip \r characters:

bash
sed -i 's/\r//g' content/**/*.md

It worked for a couple of runs, but the underlying generator (generate‑weekly.ts) still emitted Windows line endings on my local machine, so the issue resurfaced when I switched to a new laptop.

I also tried to generate the Bluesky JSON by hand‑editing bluesky_es.json each week. This worked once, but I introduced a typo ("type": "avance" instead of "type": "update"), which caused the Bluesky API to reject the payload with a 400 Bad Request.

Both approaches were band‑aid solutions; I needed a deterministic, cross‑platform generation step.

The Implementation

1. Normalize Line Endings at the Source

I added a small utility function to src/utils/normalize.ts and imported it into the generator. The function uses Node’s os.EOL to enforce Unix line endings:

ts
// src/utils/normalize.ts
export function normalizeEOL(content: string): string {
// Replace Windows CRLF with LF
return content.replace(/\r\n/g, '\n');
}

Then, in scripts/generate‑weekly.ts:

`ts
import { normalizeEOL } from '../src/utils/normalize';
import fs from 'fs';
import path from 'path';

function writeFile(filePath: string, data: string) {
const normalized = normalizeEOL(data);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, normalized, 'utf8');
}

// Example usage for Medium English post
const mediumEn = **Why I Let a Tiny Script Write My Weekly Posts (And How It Finally Stopped Screwing Up My CI)**\n\nWhen I started publishing...;
writeFile('content/2026/10/10/content-automation/medium_en.md', mediumEn);
`

Now every generated file uses LF line endings, eliminating the CI parsing error.

2. Generate Platform‑Specific Payloads from a Single Source

I introduced a JSON schema (src/schemas/bluesky-payload.schema.json) and a generator that maps the Markdown front‑matter to the required JSON shape:

`ts
// src/generators/bluesky.ts
import type { BlueskyPayload } from '../types';
import fs from 'fs';
import path from 'path';

export function generateBlueskyPayload(
title: "string,"
body: string,
lang: 'en' | 'es'
): BlueskyPayload {
return {
type: 'update',
text: ${title}\n\n${body},
language: lang,
};
}

// In generate‑weekly.ts
import { generateBlueskyPayload } from '../src/generators/bluesky';
import { writeFile } from './utils';

const blueskyPayload = generateBlueskyPayload(
'Weekly Review – Compliance Checklists & PDF Export',
mediumEn,
'en'
);
writeFile(
'content/2026/10/10/appcheck-uhpcm/bluesky_es.json',
JSON.stringify([blueskyPayload], null, 2)
);
`

The resulting bluesky_es.json (excerpt from the diff) now looks like:

json
[
{
"type": "update",
"text": "Weekly Review – Compliance Checklists & PDF Export\n\nWhen I started publishing a short “what‑I‑did‑this‑week” note on Medium, Substack...",
"language": "en"
}
]

Notice the correct "type": "update" value and the consistent structure.

3. Update Metadata Atomically

The metadata.json for each content bundle tracks which platforms have been generated. Previously I was toggling booleans manually, which caused mismatches ("medium_generated": true while Substack remained false). I refactored the metadata update to be part of the same writeFile flow:

`ts
// scripts/generate‑weekly.ts (continued)
function updateMetadata(
basePath: string,
platforms: Partial>
) {
const metaPath = path.join(basePath, 'metadata.json');
const current = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const updated = { ...current, ...platforms };
writeFile(metaPath, JSON.stringify(updated, null, 2));
}

// After generating all files
updateMetadata('content/2026/10/10/appcheck-uhpcm', {
medium_generated: true,
substack_generated: true,
bluesky_generated: true,
});
`

The diff now shows the updated metadata.json:

`diff
@@ -17,10 +17,15 @@
"pull_requests": 0,
"releases": 0,
"closed_issues": 0,

  • "medium_generated": false,
  • "substack_generated": false,
  • "medium_generated": true,
  • "substack_generated": true,
  • "bluesky_generated": true, "last_release": null } `

4. Add a New Cron Endpoint (Bonus)

While touching the repo, I added a small cron route to send daily review reminders. The changelog entry (content/.../changelog.md) references the new file src/app/api/cron/review-reminders/route.ts. Here’s the core of that endpoint:

`ts
// src/app/api/cron/review-reminders/route.ts
import { NextResponse } from 'next/server';
import { sendReminderEmails } from '../../../services/email';

export async function GET() {
try {
await sendReminderEmails();
return NextResponse.json({ status: 'ok' });
} catch (err) {
console.error('Cron error:', err);
return NextResponse.json({ status: 'error', error: err.message }, { status: 500 });
}
}
`

It runs on Vercel’s cron schedule (0 9 * * *) and ensures the checklist


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-10-11

#playadev #buildinpublic

Top comments (0)