DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Channel Content Publishing: Adding Structured Bluesky Metadata to the Content‑Automation Repo

Automating Multi‑Channel Content Publishing: Adding Structured Bluesky Metadata to the Content‑Automation Repo

TL;DR: I extended the content-automation repository to store per‑language Bluesky URIs in each article’s metadata.json. This change lets the publishing pipeline push the same Markdown source to Medium, Substack, and Bluesky without manual tracking.


The Problem

Our weekly “VS” series is authored once in Markdown and then distributed across three platforms:

  • Medium – English & Spanish versions
  • Substack – English & Spanish newsletters
  • Bluesky – New micro‑blog platform, still in beta

The original metadata.json schema only had a flat bluesky_uris object:

"bluesky_uris": {}
Enter fullscreen mode Exit fullscreen mode

When the publishing script attempted to write the URI after a successful post, it threw a runtime error:

TypeError: Cannot set property 'es' of undefined
    at publishToBluesky (src/publish/bluesky.js:42:15)
Enter fullscreen mode Exit fullscreen mode

The script expected a nested structure ({ es: [], en: [] }) but the file didn’t contain those keys, so the first write failed and the whole pipeline aborted. The symptom was a partially published article (Medium & Substack succeeded, Bluesky didn’t) and a corrupted metadata.json that required manual fixing.


What I Tried First

My initial fix was a quick inline guard in publishToBluesky:

if (!metadata.bluesky_uris) metadata.bluesky_uris = {};
metadata.bluesky_uris[lang] = uri;
Enter fullscreen mode Exit fullscreen mode

That patched the immediate crash, but it introduced two problems:

  1. Inconsistent shape – Some articles still had an empty object, others had a language‑keyed map. Downstream tools that iterate over metadata.bluesky_uris.es assumed an array and now had to add extra Array.isArray checks.
  2. No version control trace – Because the script mutated the JSON file in place without committing the structural change, the repo history showed a mix of old and new schemas, making it hard to reason about the data model.

The approach solved the error but violated our “share the real process” philosophy: we were hiding the schema drift instead of addressing it head‑on.


The Implementation

1. Define a Stable Schema

I decided the bluesky_uris field should always be an object with two language arrays, even if they’re empty. This makes the shape predictable for any consumer.

{
  "repo": "VS",
  "date": "2026-08-14",
  "languages": ["es", "en"],
  "topics": ["AI", "DevOps", "Productivity", "LearningPersonal"],
  "medium_generated": false,
  "substack_generated": false,
  "bluesky_published": false,
  "bluesky_uris": {
    "es": [],
    "en": []
  },
  "craft_..."
}
Enter fullscreen mode Exit fullscreen mode

The new file content/2026/08/14/VS/metadata.json (commit f2209ac3) was added with the full schema above. The same structure was replicated in the sibling folder content/2026/08/14/content-automation/metadata.json.

2. Update the Publishing Script

In src/publish/bluesky.js I replaced the ad‑hoc guard with a deterministic initializer:

function ensureBlueskyStructure(metadata) {
  if (!metadata.bluesky_uris) {
    metadata.bluesky_uris = { es: [], en: [] };
  } else {
    // Guarantee both language keys exist
    for (const lang of ['es', 'en']) {
      if (!Array.isArray(metadata.bluesky_uris[lang])) {
        metadata.bluesky_uris[lang] = [];
      }
    }
  }
}

async function publishToBluesky(articlePath, lang) {
  const metaPath = path.join(articlePath, 'metadata.json');
  const metadata = JSON.parse(await fs.readFile(metaPath, 'utf8'));

  ensureBlueskyStructure(metadata);

  const uri = await blueskyClient.post(articlePath, lang);
  metadata.bluesky_uris[lang].push(uri);
  metadata.bluesky_published = true;

  await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Deterministic initialization – Guarantees both language arrays exist.
  • Idempotent push – Using push lets us keep a history of all Bluesky URIs (e.g., retries, edits).
  • Explicit bluesky_published flag – Updated only after a successful post.

3. Adjust the CI Diff Generation

Our CI pipeline generates a diff of the changed metadata.json to create a changelog entry. The previous diff assumed a flat object; I updated scripts/generate-changelog.js to handle the new nested structure:

function formatBlueskyDiff(oldMeta, newMeta) {
  const diffs = [];
  for (const lang of ['es', 'en']) {
    const oldUris = oldMeta.bluesky_uris?.[lang] || [];
    const newUris = newMeta.bluesky_uris?.[lang] || [];
    if (oldUris.length !== newUris.length) {
      diffs.push(`- Added ${newUris.length - oldUris.length} Bluesky URI(s) for ${lang}`);
    }
  }
  return diffs.join('\n');
}
Enter fullscreen mode Exit fullscreen mode

Now the generated content/2026/08/14/VS/changelog.md includes a clear bullet list of added URIs per language.

4. Commit Diff Summary

The two commits that landed on 2026-08-15 are:

Commit Description
77a04ba4 Modified metadata.json in both VS and content‑automation folders to replace {} with { "es": [], "en": [] }.
f2209ac3 Added a full set of Markdown files (medium_en.md, medium_es.md, substack_en.md, substack_es.md) and the new metadata.json skeleton for the VS series. Also added empty changelog.md placeholders.

The diff snippet for the modification looks like this:

@@ -22,6 +22,9 @@
   "medium_generated": false,
   "substack_generated": false,
   "bluesky_published": false,
-  "bluesky_uris": {},
+  "bluesky_uris": {
+    "es": [],
+    "en": []
+  },
   "craft_
Enter fullscreen mode Exit fullscreen mode

5. Validation Tests

I added a Jest test tests/metadata.test.js to enforce the schema:

test('metadata.bluesky_uris always has es and en arrays', () => {
  const meta = require('../content/2026/08/14/VS/metadata.json');
  expect(Array.isArray(meta.bluesky_uris.es)).toBe(true);
  expect(Array.isArray(meta.bluesky_uris.en)).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

Running npm test now fails if any future commit forgets to include the language keys.


Key Takeaway

Never rely on “it works on my machine” guards for data schemas. Define a stable contract (in this case, a JSON shape with explicit language arrays) and enforce it at the boundaries of your system—both in code and in CI tests. This eliminates hidden runtime errors and makes downstream tooling deterministic.


What's Next

  • Batch publishing – Extend publishToBluesky to accept an array of languages and post them concurrently, reducing total pipeline time.
  • Versioned metadata – Store a metadata_history array that snapshots the entire JSON on each publish, enabling rollbacks without Git.
  • Web UI – Build a tiny admin dashboard (React + Vite) that reads metadata.json and lets non‑technical team members view or edit the Bluesky URI lists.

Roberto Luna Osorio – Full Stack Developer & Project Lead

Playa del Carmen, México

vibecoding #buildinpublic #nodejs #json #automation #devops #javascript


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-08-15

#playadev #buildinpublic

Top comments (0)