The Problem: Rules Nobody Reads
My content style guide defines 9 canonical categories, 53 canonical tags, character limits for titles and excerpts, an em dash ban on short-form surfaces, and a requirement that every post has a unique cover image. After 50 MDX files across three content types, the guide had drifted: tags showed up in non-canonical forms, two project case studies shared the same Unsplash photo, and an excerpt had crept past 160 characters. The rules existed; the enforcement didn't.
I had to decide whether to check these rules with an LLM reviewer or with deterministic code. A language model can catch subjective tone issues, but character counts and enum membership aren't subjective; they're boolean. So I reached for Zod.
The Schema
The schema encodes every machine-checkable rule from the style guide as a type constraint. The core structure is a discriminated union on the type field:
const baseSchema = z.object({
title: z.string().min(1).max(60, 'Title must be ≤ 60 characters'),
excerpt: z
.string()
.max(160, 'Excerpt must be ≤ 160 characters')
.refine(
val => !val.includes('\u2014'),
'Excerpt must not contain em dashes (per style guide)'
),
category: z.enum(CATEGORIES),
tags: z
.array(z.string())
.min(3)
.max(8)
.refine(
tags => tags.every(t => canonicalTagSet.has(t)),
'All tags must be from the canonical tag set'
),
cover: coverSchema,
// ... date, author, slug, type
});
export const postMetadataSchema = z.discriminatedUnion('type', [
blogSchema,
projectSchema,
experienceSchema,
]);Blog entries need nothing beyond the base fields. Project entries accept optional featured, company, role, and duration. Experience entries require company, role, duration, industry, logo, and invert. The discriminated union means Zod picks the right branch off type before it validates the rest.
The canonical vocabularies are literal arrays: 9 categories, 53 tags. A tag merge map handles the known synonyms (a11y to Accessibility, CSS3 to CSS). Adding a new category or tag means editing the schema, which means editing the style guide first, and that's the whole point: vocabulary changes should be deliberate, not accidental.
The Runner
The validation script discovers every .mdx file under data/content/, extracts the export const metadata block via regex, and feeds each one through safeParse:
function extractMetadata(filePath: string): unknown {
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(
/export\s+const\s+metadata\s*=\s*(\{[\s\S]*?\n\});?/
);
if (!match) throw new Error(`No metadata export found in ${filePath}`);
return new Function(`return ${match[1]}`)();
}After schema validation, the runner does the cross-file checks that Zod can't express: unique cover.src across all 50 files, a unique slug within each content type, a slug-filename match, and a warning when a category also shows up in that entry's tags.
The script wires into the Nx build pipeline as a validate-content target that runs before every build, so a content error fails the build, not just the lint step.
What It Caught on Day One
The first run turned up a duplicate cover image: ui-components-v1 and ui-components-v2 shared the same Unsplash photo ID. Both posts had been reviewed on their own, but neither review caught the collision because no human was comparing cover images across 50 files. The script caught it with a Map lookup.
It also raised tag synonym warnings for entries using a11y instead of Accessibility and CSS3 instead of CSS. Those weren't errors, since the merge map handles them, but the warnings flag entries I should clean up to the canonical forms.
The Decision Framework
Not everything in the style guide belongs in a schema. "Lead with the problem, not preamble" is a judgment call, while "title must be ≤ 60 characters" is a boolean. The rule I follow: if a rule can be expressed as a type constraint, a regex, or a set-membership check, encode it; if it needs reading comprehension, leave it to human review.
The executable style guide now validates 12 distinct rules across 50 files in under a second, and the prose guide still exists for tone, structure, and voice. They cover different ground: the schema catches what machines check well, and the prose guides what humans judge well.
Rules that run on every build don't drift.
