DEV Community

Gorilla Vibe
Gorilla Vibe

Posted on

Validating JSON-LD in CI with a small Node script

Structured data breaks quietly. A template change drops a closing brace, a CMS field comes back empty, and the page still renders perfectly. Nobody notices until someone opens a testing tool weeks later. A short script in your build pipeline catches the most common failures before they ship.

This article shows a dependency-free Node script that reads built HTML files, extracts every JSON-LD block and fails the build if something is wrong.

What the script checks

It is deliberately modest. It checks four things:

  1. Each application/ld+json block parses as valid JSON.
  2. Each block declares a schema.org context.
  3. Every entity has an @type.
  4. Entities of a type you list have the properties you decided they must have.

The last rule is yours, not Google's. Treat the required table in the script as house rules for your own templates, and adjust it to match what your pages are meant to output.

The script

Save this as scripts/check-jsonld.mjs:

import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";

const root = process.argv[2] ?? "dist";
const blockPattern =
  /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;

// House rules: edit to match your templates.
const required = {
  Article: ["headline", "datePublished"],
  BlogPosting: ["headline", "datePublished"],
  FAQPage: ["mainEntity"],
  BreadcrumbList: ["itemListElement"],
};

function* htmlFiles(dir) {
  for (const name of readdirSync(dir)) {
    const path = join(dir, name);
    if (statSync(path).isDirectory()) yield* htmlFiles(path);
    else if (name.endsWith(".html")) yield path;
  }
}

let errors = 0;
const fail = (file, message) => {
  console.error(`${file}: ${message}`);
  errors += 1;
};

for (const file of htmlFiles(root)) {
  const html = readFileSync(file, "utf8");
  for (const match of html.matchAll(blockPattern)) {
    let data;
    try {
      data = JSON.parse(match[1]);
    } catch (err) {
      fail(file, `invalid JSON (${err.message})`);
      continue;
    }
    const context = JSON.stringify(data["@context"] ?? "");
    if (!Array.isArray(data) && !context.includes("schema.org")) {
      fail(file, "missing schema.org @context");
    }
    const entities = Array.isArray(data) ? data : (data["@graph"] ?? [data]);
    for (const entity of entities) {
      if (!entity["@type"]) {
        fail(file, "entity without @type");
        continue;
      }
      const types = [].concat(entity["@type"]);
      for (const type of types) {
        for (const prop of required[type] ?? []) {
          if (entity[prop] === undefined || entity[prop] === "") {
            fail(file, `${type} is missing "${prop}"`);
          }
        }
      }
    }
  }
}

console.log(errors ? `${errors} problem(s) found` : "JSON-LD OK");
process.exit(errors ? 1 : 0);
Enter fullscreen mode Exit fullscreen mode

The @type field can be a string or an array, which is why the script wraps it with [].concat(...). A @graph array is handled by checking each entity inside it.

Wiring it into the pipeline

Add an npm script and run it after the build step:

{
  "scripts": {
    "check:jsonld": "node scripts/check-jsonld.mjs dist"
  }
}
Enter fullscreen mode Exit fullscreen mode

In your CI configuration, run npm run build first and npm run check:jsonld straight after. Because the script exits with code 1 on any problem, the job fails and the pull request is blocked. Swap dist for whatever your build output folder is called.

What it will not tell you

Passing this script means your markup is well formed and complete by your own rules. It does not mean the page is eligible for any search feature, and it does not check that the markup matches what visitors can see on the page. For those questions, use Google's Rich Results Test and the Schema Markup Validator on a few representative URLs, and review changes to templates by hand.

A few practical tips:

  • Keep the required table short. A rule nobody understands gets deleted the first time it blocks a release.
  • Print the file path with every error, as the script does, so the fix is obvious from the CI log.
  • If your site is rendered in the browser rather than at build time, this script will find nothing in the HTML. Test against server-rendered output instead.
  • Add a test fixture with one deliberately broken page, so you know the script still fails when it should.

Where to go next

Once the basic check is stable you can extend it with per-template rules, such as requiring a BreadcrumbList on every page under /blog/. If you would rather hand the wider technical audit to a team, there is technical SEO support from Gorilla Vibe on offer.

Written with AI assistance for the Gorilla Vibe marketing team.

Top comments (0)