DEV Community

Roberto Luna
Roberto Luna

Posted on

Centralizing Content Metadata with a JSON Manifest in the `content‑automation` Repo

Centralizing Content Metadata with a JSON Manifest in the content‑automation Repo

TL;DR: I added a metadata.json file next to each content batch to keep repository‑wide metadata in a single source of truth. This change eliminates ad‑hoc scripts that scraped commit logs and makes the build pipeline deterministic.


The Problem

Our static‑site generator for VibeCoding pulls article data from a deep folder hierarchy (content/YYYY/MM/DD/…). The build script needed to know three things for each batch:

  1. Which repository the content belongs to.
  2. The publication date.
  3. The list of languages and topics.

Originally we inferred this information by parsing Git history and file names at runtime. That approach caused two concrete issues:

  • Inconsistent data – When a commit was rebased or a branch was squashed, the inferred dates drifted, leading to duplicate timestamps in the final RSS feed.
  • Performance hit – Running git log --pretty=format for every folder added ~200 ms per build on a 500‑article site, which compounded to minutes on CI.

The symptom was a failing CI step that timed out while trying to generate the sitemap.xml. The error message read:

Error: Build timed out after 10 minutes – sitemap generation incomplete.
Enter fullscreen mode Exit fullscreen mode

What I Tried First

My first attempt was to write a small Bash helper (scripts/extract-meta.sh) that used git log to pull the latest commit date for each folder and then exported those values as environment variables. The script looked like this:

#!/usr/bin/env bash
DIR=$1
DATE=$(git log -1 --format=%cd --date=short "$DIR")
echo "CONTENT_DATE=$DATE"
Enter fullscreen mode Exit fullscreen mode

I called the script from the Node build step with child_process.execSync. While it worked on my local machine, the CI environment (a Docker container without a full Git history) returned empty strings, causing the build to crash with:

TypeError: Cannot read property 'toISOString' of undefined
Enter fullscreen mode Exit fullscreen mode

I also tried to cache the results in a temporary JSON file, but the cache became stale whenever a branch was rebased, leading to the same inconsistency problem.

The Implementation

1. Add a static manifest per content batch

I introduced a metadata.json file directly under the batch folder (content/2026/11/08/content-automation/metadata.json). The file is version‑controlled, so the data never diverges from the source.

{
  "repo": "content-automation",
  "date": "2026-11-08",
  "languages": [
    "es",
    "en"
  ],
  "topics": [
    "Productivity"
  ],
  "commits": 4,
  "pull_requests": null
}
Enter fullscreen mode Exit fullscreen mode

The diff that landed with the commit 580b652a added exactly this file:

+{
+  "repo": "content-automation",
+  "date": "2026-11-08",
+  "languages": [
+    "es",
+    "en"
+  ],
+  "topics": [
+    "Productivity"
+  ],
+  "commits": 4,
+  "pull_requests":
Enter fullscreen mode Exit fullscreen mode

(The file ends with an incomplete pull_requests field; we’ll fix that later.)

2. Update the build pipeline to read the manifest

I replaced the Bash helper with a tiny Node module (src/utils/readMetadata.ts). It walks the content tree, loads each metadata.json, and merges the data into the site model.

// src/utils/readMetadata.ts
import { promises as fs } from 'fs';
import * as path from 'path';

export interface ContentMeta {
  repo: string;
  date: string; // ISO date string
  languages: string[];
  topics: string[];
  commits: number;
  pull_requests: number | null;
}

/**
 * Recursively scans a base directory for `metadata.json` files
 * and returns a map keyed by the relative folder path.
 */
export async function loadMetadata(baseDir: string): Promise<Record<string, ContentMeta>> {
  const entries = await fs.readdir(baseDir, { withFileTypes: true });
  const result: Record<string, ContentMeta> = {};

  for (const entry of entries) {
    const fullPath = path.join(baseDir, entry.name);
    if (entry.isDirectory()) {
      const sub = await loadMetadata(fullPath);
      Object.assign(result, sub);
    } else if (entry.isFile() && entry.name === 'metadata.json') {
      const raw = await fs.readFile(fullPath, 'utf-8');
      const meta: ContentMeta = JSON.parse(raw);
      // Use the folder containing the file as the key
      const folderKey = path.relative(baseDir, path.dirname(fullPath));
      result[folderKey] = meta;
    }
  }

  return result;
}
Enter fullscreen mode Exit fullscreen mode

The main generator (src/generator.ts) now calls loadMetadata once at startup:

import { loadMetadata } from './utils/readMetadata';

async function generateSite() {
  const metaMap = await loadMetadata(path.resolve(__dirname, '..', 'content'));

  for (const [folder, meta] of Object.entries(metaMap)) {
    // Example: feed the meta into the article model
    const article = await buildArticleFromFolder(folder, meta);
    site.addArticle(article);
  }

  await site.render();
}
Enter fullscreen mode Exit fullscreen mode

3. Adjust the CI script

The CI YAML (.github/workflows/build.yml) previously executed scripts/extract-meta.sh. I removed that step and added a simple npm run build which now includes the metadata loader. The relevant snippet:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install deps
        run: npm ci
      - name: Build site
        run: npm run build
Enter fullscreen mode Exit fullscreen mode

No more reliance on a full Git history; the build now finishes in ~30 seconds for the same 500‑article dataset.

4. Validate the manifest during CI

To catch malformed JSON early, I added a linting script (scripts/validateMeta.ts) that runs before the build:

// scripts/validateMeta.ts
import { loadMetadata } from '../src/utils/readMetadata';

(async () => {
  try {
    await loadMetadata(path.resolve(__dirname, '..', 'content'));
    console.log('✅ All metadata files are valid JSON.');
  } catch (err) {
    console.error('❌ Invalid metadata file:', err);
    process.exit(1);
  }
})();
Enter fullscreen mode Exit fullscreen mode

The CI now runs node scripts/validateMeta.ts as a pre‑step. If a developer forgets to close a JSON object (as the initial commit did with the dangling pull_requests key), the pipeline fails fast with a clear error:

❌ Invalid metadata file: Unexpected end of JSON input at position 123
Enter fullscreen mode Exit fullscreen mode

Key Takeaway

Storing static metadata alongside the content it describes removes the need for runtime introspection (e.g., Git parsing) and makes builds deterministic, faster, and easier to test. A small, version‑controlled JSON manifest is a lightweight contract that scales with the number of articles without adding runtime overhead.

What's Next

  • Complete the pull_requests field – Hook the PR API (GitHub GraphQL) into a CI job that writes the count into metadata.json after each merge.
  • Add schema validation – Introduce a JSON Schema (metadata.schema.json) and enforce it with ajv in the validation script.
  • Expose the manifest via an API – Serve the aggregated metadata as /api/content-index.json for client‑side search.

Tags: #vibecoding #buildinpublic #json #nodejs #static-site-generation #ci


Roberto Luna Osorio – Full Stack Developer & Project Lead

Playa del Carmen, México


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

#playadev #buildinpublic

Top comments (0)