Claude Code hook: validate frontmatter on every Edit
Recipe #3 in the cookbook, after the starter CWD guard and the skill-activation logger. A PostToolUse hook on Edit|Write|MultiEdit that validates Astro content frontmatter the moment a content file is touched — instead of waiting for astro build to fail at the end of a session. The hook checks the same shape src/content.config.ts enforces at build (required fields present, pubDate parses as a date, draft is a boolean), but as a dependency-free Node script that runs in milliseconds. On a structural failure it exits 2 with a structured decision: block reason — Claude reads the stderr and self-corrects on the next turn. Two live blocks captured this same session, both reproduced verbatim below: a pubDate: tomorrow from a model freeform date, and a missing description after a sloppy multi-line edit.
The hook: validate content frontmatter at edit-time
Goal: every time Claude Code edits a file under src/content/blog/ or src/content/skills/, parse the frontmatter and check the required fields against the same shape src/content.config.ts enforces at build. If anything is off, exit 2 with a structured decision: block reason. Claude reads the stderr, sees what’s wrong, and self-corrects on the next turn.
This is the cheapest possible “code quality” hook for a content site: zod runs anyway at build, but build runs once at the end of a 20-minute session. By the time it fails, the model has moved on to other files. Hoisting the check to edit-time turns a “find out later” failure into a “fix it now” failure.
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/check-frontmatter.mjs",
"timeout": 5
}
]
}
]
}
}
.claude/hooks/check-frontmatter.mjs — reads the hook payload, exits early on tool calls outside the two content collections, parses the frontmatter, and blocks on missing required fields, unparseable pubDate, non-boolean draft, or unknown receiptsStatus values:
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolve, sep, posix } from 'node:path';
const REQUIRED_BLOG = ['title', 'description', 'pubDate'];
const REQUIRED_SKILL = ['title', 'description', 'useCase', 'pubDate'];
let raw = '';
process.stdin.on('data', (c) => (raw += c));
process.stdin.on('end', () => {
let payload;
try { payload = JSON.parse(raw); } catch { process.exit(0); }
if (!['Edit', 'Write', 'MultiEdit'].includes(payload.tool_name)) process.exit(0);
const filePath = payload.tool_input?.file_path;
if (!filePath) process.exit(0);
const norm = filePath.split(sep).join(posix.sep);
const isBlog = /\/src\/content\/blog\/[^/]+\.mdx?$/.test(norm);
const isSkill = /\/src\/content\/skills\/[^/]+\.mdx?$/.test(norm);
if (!isBlog && !isSkill) process.exit(0);
const body = readFileSync(resolve(filePath), 'utf8');
const fm = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fm) block('frontmatter block missing — file must start with --- ... ---');
const fields = parseFrontmatter(fm[1]);
const required = isBlog ? REQUIRED_BLOG : REQUIRED_SKILL;
const missing = required.filter((k) => !(k in fields) || fields[k] === '');
if (missing.length) {
block(`missing required frontmatter field(s): ${missing.join(', ')}`);
}
if (Number.isNaN(new Date(fields.pubDate).getTime())) {
block(`pubDate "${fields.pubDate}" does not parse as a date — use YYYY-MM-DD`);
}
process.exit(0);
});
function parseFrontmatter(yaml) {
const out = {};
for (const line of yaml.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)$/);
if (!m) continue;
out[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
}
return out;
}
function block(reason) {
process.stderr.write(JSON.stringify({ decision: 'block', reason }) + '\n');
process.exit(2);
}
The script is dependency-free on purpose. Frontmatter in this project is a well-constrained subset of YAML — key: value pairs, the occasional inline array — so a 10-line regex parser is enough to catch the failure modes that actually happen in authoring. The full zod schema still runs at build; the hook is a cheap pre-filter, not a replacement.
Two live blocks, captured this session
Both came from triggering the hook on a deliberate test file in this same session it was installed.
1. pubDate: tomorrow — the model’s freeform date.
PostToolUse:Write hook blocking error from command:
"node .claude/hooks/check-frontmatter.mjs":
{"decision":"block","reason":"pubDate \"tomorrow\" does not parse as a date — use YYYY-MM-DD"}
The reason field surfaces directly as feedback. One Edit later — pubDate: 2026-04-30 — the hook passes silently. This is the entire self-correct loop: structured stderr in, fix on the next turn, no human in the middle.
2. Missing description after a sloppy multi-line Edit.
PostToolUse:Edit hook blocking error from command:
"node .claude/hooks/check-frontmatter.mjs":
{"decision":"block","reason":"missing required frontmatter field(s): description"}
This is the failure mode that motivated the hook in the first place. Without it, a missing description on a blog post slips through every check until astro build runs at the end of the session, by which point the model is three files past the bug. The hook turns a 20-minute round-trip into a one-turn correction.
What this hook does not catch
Worth being honest about the gap. The hook checks presence of required fields, pubDate parses as a date, draft is a boolean, and receiptsStatus is one of the three allowed values. It does not re-implement zod. So:
- A typo in a
tagsarray (tags: claude-codeinstead oftags: ["claude-code"]) won’t block — zod will catch it at build. - A
sourceUrlthat isn’t a valid URL won’t block — zod will catch it at build. - A
heropath pointing at a missing file won’t block — that’s a runtime concern, not a frontmatter concern.
The hook is deliberately under-spec relative to zod. It’s a cheap edit-time filter for the failure modes that are common, plain-text, and detectable without parsing YAML properly. Anything that needs the full schema still gets caught at build — just later.
Watcher caveat (still relevant)
The watcher quirk from recipe #2 applies here too: if .claude/settings.json didn’t exist at session start, adding it mid-session won’t be picked up until you open /hooks or restart Claude Code. In this case settings.json already existed (it had the Skill matcher from recipe #2), so the new Edit|Write|MultiEdit matcher was picked up live — both blocks above came from the same session the hook was installed in. If the hook seems silent on a fresh project, that’s the watcher, not the script.
What’s next
Nine more hooks lined up. Each one ships only after a real run on real code. Subscribe to RSS for the rest.