HookStack
Back to catalogue
ValidationPostToolUse· Write|Edit

SEO JSON-LD schema validation

Broken JSON-LD schema caught the moment it's written

After an edit to an HTML-like file (html, htm, jsx, tsx, vue, svelte, php, ejs), parses every <script type="application/ld+json"> block and flags invalid JSON, a missing @context or @type, placeholder text like [Business Name], and deprecated or retired schema.org types. Non-blocking for structural issues, but blocks (exit code 2) when a file ships placeholders or deprecated types that would silently kill rich results.

What does the SEO JSON-LD schema validation hook do?

SEO JSON-LD schema validation is a Claude Code PostToolUse hook matching Write|Edit. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Broken JSON-LD schema caught the moment it's written.

As a PostToolUse hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Never ship placeholder text or deprecated types to production
  • Keep structured data valid across headless and SEO projects
  • Protect rich results eligibility on every content edit

Tags

#seo#jsonld#schema#structured-data#validation#rich-results

settings.json fragment

{
  "hooks": {
    "PostToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/seo-schema-validation.mjs",
            "type": "command"
          }
        ],
        "matcher": "Write|Edit"
      }
    ]
  }
}

Script · .claude/hooks/seo-schema-validation.mjs

#!/usr/bin/env node
// @hookstack seo-schema-validation
// Validates JSON-LD schema.org blocks after editing an HTML-like file (PostToolUse Write|Edit).
// Adapted from https://github.com/AgriciDaniel/claude-seo (hooks/validate-schema.py).
// Blocking (exit code 2) when a block ships placeholder text or deprecated/retired
// schema.org types; a non-blocking warning otherwise. Silent when no markup is present.
import { readFileSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";

// <script type="application/ld+json">…</script>, attributes order-insensitive-ish (type first).
const LD_JSON_RE =
	/<script\s+type=["']application\/ld\+json["']\s*>(.*?)<\/script>/gis;

const VALID_EXTENSIONS = [
	".html",
	".htm",
	".jsx",
	".tsx",
	".vue",
	".svelte",
	".php",
	".ejs",
];

// Files above this size are typically generated/minified bundles — skip them to
// bound memory and hook latency.
const MAX_FILE_BYTES = 10 * 1024 * 1024;

// Placeholder text that must never reach production (Google rejects it and
// competitors index it).
const PLACEHOLDERS = [
	"[Business Name]",
	"[City]",
	"[State]",
	"[Phone]",
	"[Address]",
	"[Your",
	"[INSERT",
	"REPLACE",
	"[URL]",
	"[Email]",
];

// Types Google no longer accepts for rich results. Retired types also stop
// being valid Schema.org vocabulary.
const DEPRECATED_TYPES = {
	HowTo: "deprecated September 2023",
	SpecialAnnouncement: "deprecated July 31, 2025",
	CourseInfo: "retired June 2025",
	EstimatedSalary: "retired June 2025",
	LearningVideo: "retired June 2025",
	ClaimReview: "retired June 2025; fact-check rich results discontinued",
	VehicleListing:
		"retired June 2025; vehicle listing structured data discontinued",
};

function defaultGetSize(filePath) {
	return statSync(filePath).size;
}

export function run(
	input,
	{ readFile = readFileSync, getSize = defaultGetSize } = {},
) {
	const filePath = input.tool_input?.file_path ?? "";
	if (!VALID_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext))) {
		return null;
	}

	let size;
	try {
		size = getSize(filePath);
	} catch {
		return null; // file missing/unreadable → nothing to validate
	}
	if (size > MAX_FILE_BYTES) return null;

	let content;
	try {
		content = readFile(filePath, "utf8");
	} catch {
		return null;
	}

	const errors = validateBlocks(content);
	if (!errors.length) return null;

	const critical = errors.filter((e) =>
		/placeholder|deprecated|retired/i.test(e),
	);
	const warnings = errors.filter((e) => !critical.includes(e));

	const lines = [];
	if (warnings.length) {
		lines.push("⚠️  Schema validation warnings:");
		lines.push(...warnings.map((w) => `  - ${w}`));
	}
	if (critical.length) {
		lines.push("🛑 Schema validation ERRORS (blocking):");
		lines.push(...critical.map((e) => `  - ${e}`));
	}
	const message = `[schema] ${filePath}\n${lines.join("\n")}\n`;

	return critical.length ? { exitCode: 2, message } : { message };
}

function validateBlocks(content) {
	const errors = [];
	let blockNum = 0;
	for (const match of content.matchAll(LD_JSON_RE)) {
		blockNum++;
		const raw = match[1].trim();
		let data;
		try {
			data = JSON.parse(raw);
		} catch (e) {
			errors.push(`Block ${blockNum}: invalid JSON; ${e.message}`);
			continue;
		}
		const objects = Array.isArray(data) ? data : [data];
		for (const obj of objects) validateObject(obj, blockNum, errors);
	}
	return errors;
}

function validateObject(obj, blockNum, errors) {
	if (typeof obj !== "object" || obj === null) {
		errors.push(`Block ${blockNum}: expected a JSON object`);
		return;
	}
	const prefix = `Block ${blockNum}`;

	if (!Object.hasOwn(obj, "@context")) {
		errors.push(`${prefix}: missing @context`);
	} else if (
		obj["@context"] !== "https://schema.org" &&
		obj["@context"] !== "http://schema.org"
	) {
		errors.push(`${prefix}: @context should be 'https://schema.org'`);
	}

	if (!Object.hasOwn(obj, "@type")) {
		errors.push(`${prefix}: missing @type`);
	}

	const text = JSON.stringify(obj);
	for (const placeholder of PLACEHOLDERS) {
		if (text.toLowerCase().includes(placeholder.toLowerCase())) {
			errors.push(`${prefix}: contains placeholder text: ${placeholder}`);
		}
	}

	const types = Array.isArray(obj["@type"]) ? obj["@type"] : [obj["@type"]];
	for (const type of types) {
		if (type && DEPRECATED_TYPES[type]) {
			errors.push(`${prefix}: @type '${type}' is ${DEPRECATED_TYPES[type]}`);
		}
	}
}

/* v8 ignore next 6 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result?.message) process.stderr.write(result.message);
	if (result?.exitCode) process.exit(result.exitCode);
}

Learn more

Related hooks