DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing: Fixing Bluesky JSON Validation and Keeping Metadata in Sync

Automating Multi‑Platform Content Publishing: Fixing Bluesky JSON Validation and Keeping Metadata in Sync

TL;DR: I updated the content‑automation pipeline to generate a schema‑compliant Bluesky JSON payload and synchronized the metadata.json flags after each successful publish. The changes eliminate runtime validation errors and give us a reliable source‑of‑truth for what has been generated.


The Problem

Our weekly “content‑automation” job creates markdown articles for Medium and Substack, then builds platform‑specific payloads for Bluesky. The last run (2026‑10‑14) failed with the following error from the Bluesky validation endpoint:

POST https://api.bsky.app/xrpc/com.atproto.repo.createRecord
Response 400: {"error":"InvalidRequest","message":"JSON schema validation failed: required property 'text' missing"}
Enter fullscreen mode Exit fullscreen mode

The script was reading content/2026/10/13/VS/bluesky_es.json, but the generated file was missing the required text field and contained an extra type value that the API rejects. At the same time, metadata.json still reported "medium_generated": false even though the Medium markdown files had been written, causing our CI checks to think the publish step was incomplete.


What I Tried First

My first attempt was to patch the JSON after it was written, adding a text property manually:

// scripts/generate_bluesky_payload.js (initial version)
const payload = JSON.parse(fs.readFileSync(srcPath, "utf8"));
payload.text = payload.text || ""; // quick fix
fs.writeFileSync(destPath, JSON.stringify(payload, null, 2));
Enter fullscreen mode Exit fullscreen mode

I also added a post‑publish step that toggled the flags in metadata.json:

// scripts/update_metadata.js (initial version)
const meta = JSON.parse(fs.readFileSync("content/2026/10/14/content-automation/metadata.json"));
meta.medium_generated = true;
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
Enter fullscreen mode Exit fullscreen mode

Both patches compiled, but the API still rejected the payload because the type field in the JSON array ("avance" for Spanish, "progress" for English) is not part of the allowed schema. Moreover, the manual text injection left the content empty, resulting in blank posts on Bluesky.


The Implementation

1. Refactor the Bluesky payload generator

The generator now builds the payload from a typed model instead of copying raw JSON. I introduced src/models/blueskyPost.js:

// src/models/blueskyPost.js
class BlueskyPost {
  /**
   * @param {string} language  'en' | 'es'
   * @param {string} headline  Title of the post
   * @param {string} body      Full markdown content
   */
  constructor(language, headline, body) {
    this.$type = "app.bsky.feed.post";
    this.lang = language;
    this.text = `${headline}\n\n${body}`;
    this.createdAt = new Date().toISOString();
  }

  toJSON() {
    // Only keep properties required by the API
    const { $type, lang, text, createdAt } = this;
    return { $type, lang, text, createdAt };
  }
}

module.exports = BlueskyPost;
Enter fullscreen mode Exit fullscreen mode

The generator script (scripts/generate_bluesky_payload.js) now imports this model:

// scripts/generate_bluesky_payload.js
const fs = require("fs");
const path = require("path");
const BlueskyPost = require("../src/models/blueskyPost");

// Helper to read markdown source
function readMarkdown(date, locale) {
  const mdPath = path.join(
    __dirname,
    `../content/${date}/VS/${locale === "en" ? "medium_en.md" : "medium_es.md"}`
  );
  return fs.readFileSync(mdPath, "utf8");
}

// Build payload for both locales
function buildPayload(date) {
  const enBody = readMarkdown(date, "en");
  const esBody = readMarkdown(date, "es");

  const enPost = new BlueskyPost("en", "Weekly Build Log – Receipts, Keep‑Alive, and Automation", enBody);
  const esPost = new BlueskyPost("es", "Esta semana en content‑automation – keep‑alive, recibos y automatización", esBody);

  return [enPost.toJSON(), esPost.toJSON()];
}

// Write JSON files that match the API schema
function writePayload(date) {
  const payload = buildPayload(date);
  const outDir = path.join(__dirname, `../content/${date}/VS`);
  fs.mkdirSync(outDir, { recursive: true });

  // English payload
  fs.writeFileSync(
    path.join(outDir, "bluesky_en.json"),
    JSON.stringify([payload[0]], null, 2)
  );
  // Spanish payload
  fs.writeFileSync(
    path.join(outDir, "bluesky_es.json"),
    JSON.stringify([payload[1]], null, 2)
  );
}

// Run for the target date
const targetDate = "2026/10/13";
writePayload(targetDate);
console.log("✅ Bluesky payload generated");
Enter fullscreen mode Exit fullscreen mode

Key changes:

  • Explicit schema – The BlueskyPost class guarantees $type, lang, text, and createdAt are present.
  • No stray type field – The previous type: "avance" / "progress" entries were removed.
  • Single source of truth – Both English and Spanish posts pull the same markdown files used for Medium/Substack, preventing drift.

2. Update the Git diff for the new JSON files

The commit that added the corrected payloads (c15cdc88) now contains clean JSON:

// content/2026/10/14/content-automation/bluesky_en.json
[
  {
    "$type": "app.bsky.feed.post",
    "lang": "en",
    "text": "Weekly Build Log – Receipts, Keep‑Alive, and Automation\n\nI woke up on a Tuesday morning ...",
    "createdAt": "2026-10-14T07:05:12.000Z"
  }
]
Enter fullscreen mode Exit fullscreen mode
// content/2026/10/14/content-automation/bluesky_es.json
[
  {
    "$type": "app.bsky.feed.post",
    "lang": "es",
    "text": "Esta semana en content‑automation – keep‑alive, recibos y automatización\n\nCuando me desperté ...",
    "createdAt": "2026-10-14T07:05:12.000Z"
  }
]
Enter fullscreen mode Exit fullscreen mode

3. Synchronize metadata flags automatically

Instead of a separate script, I embedded the flag update into the main pipeline (scripts/publish_all.js):


js
// scripts/publish_all.js
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");

function updateMetadata(flags) {
  const metaPath = path.join(
    __dirname,
    "..",
    "content/2026/10/14/content-automation/metadata.json"
  );
  const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
  Object.assign(meta, flags);

---

*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-10-15*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)