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:
-
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. -
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)