A static site has no runtime. When something is in a bad state there's no code around to throw — slightly wrong HTML just gets deployed, and the browser never complains.
So bad states persist. Three rules broke repeatedly on this site.
All three were documented. All three broke. All three now fail the build — and none has broken since.
1. What Actually Happens When They Break
The important property they share: none of them is visible right away. That's precisely why they belong in the build.
A post in only one locale
The layout emits two hreflang links on every post — the Korean page points at the English URL and vice versa. Ship one locale and the live page advertises a URL that doesn't exist.
In a browser everything looks perfect; <link> tags are invisible. Only search engines see the inconsistency.
Dates that disagree
This one is nastier. Scheduled publishing decides per file from its own date, so Korean on August 30 and English on September 1 produces exactly the state above for two days — with both files present in the repo the whole time.
Nothing looks wrong at commit time, it heals itself two days later, and nobody notices in between. It is not the kind of error a human catches.
Links pointing forward
Link from the August 30 post to the September 4 post and it 404s for five days. When you write the link the target file is sitting right there, so there's apparently nothing to check.
Code review can't catch these. A reviewer looks at files, while the defect lives in when those files enter the output.
2. Check Everything Once, Before the Build
Eleventy fires an eleventy.before event once, ahead of template processing. We read the source files there directly.
Running once per build rather than per post matters. Symmetry and link direction are properties of the whole set — no single post carries enough information to judge them.
Why the build, and not CI or a commit hook
There were three places to hang the check.
The build won because there's nowhere to route around it. Every deploy goes through a build. Put the check inside it and "deploy without running the check" stops being a path that exists.
There's a second benefit. A local npm run build and a deploy run the same check. Put it only in CI and you get things that pass locally and fail on push — and every one of those is a round trip. When an agent is doing the work, round trips are expensive.
The cost is real too: the check now runs on every build — every save under npm run dev, every preview deploy. Which turns "read each file exactly once," in the next section, from a performance nicety into a usability requirement.
Comparing two file listings is the entire check. Ten lines outperform a page of documentation.
3. Read Each File Exactly Once
The remaining checks need file contents. Rather than let each one read independently, we read everything up front and reuse it.
Note that date is scraped with a regex and kept as a string. No YAML parser, no conversion to a Date.
Because dates are YYYY-MM-DD, lexicographic comparison is date comparison. No time zones, no parse failures, no dependency. Different string means different date; greater string means later date.
A complicated guard grows bugs of its own — and nothing is checking the guard.
Worth measuring the cost too, since it rides along on every build and grows with the post count.
Not a number worth acting on today. If the post count multiplies until it's noticeable on every save under npm run dev, the fix is to read only the front matter instead of the whole file. We haven't done it preemptively because keeping the guard simple is worth more than those tens of milliseconds.
4. Date Agreement and Link Direction
Both remaining checks run off that one posts map.
The link check covers two failures at once: does the target exist (typos, deleted posts) and does it publish no later than the linking post. The second exists only because of scheduled publishing.
Scraping links with a regex looks crude, and here it's entirely sufficient — the scope is same-locale post links in a fixed format. There's no argument for pulling in an HTML parser.
5. Make the Error Message the Fix Instruction
Building these guards, the messages took longer to get right than the code.
The good one delivers what, where, and why at once. That middle sentence — "publish times will diverge" — carries real weight: someone who has no idea why the dates must match understands after reading it.
And violations are collected and reported together. Stopping at the first turns fixing into a fix-rerun loop. It matters especially when an AI agent is doing the work: one failed build tells it everything left to do.
We fixed four elements every message has to carry.
"The values" pulls more weight than it looks. Printing both dates side by side usually makes the typo obvious at a glance. Without it you're opening two files to compare.
What has actually tripped it
Since the guards went in, three kinds of violation have failed a build.
The first dominates. And it was the least visible kind to begin with — a ko-only post leaves the site working perfectly, with one missing row in an English list.
The second had a pattern we didn't anticipate: it fires most when writing several posts in one batch. Write eight in a row, link from the third to the seventh, and it trips. Both files exist in the repository, so to a human reviewer nothing looks wrong.
The third is rare, but not because the guard deters it — it's rare because both files get created in the same commit, so there's little opportunity to edit one date alone. We kept it anyway, because it's the kind that would be discovered latest of the three.
6. Deciding What Deserves a Guard
Not every rule belongs in the build. We picked these three on three tests.
- Does it break silently? Errors that still look correct in a browser after deploy are the top priority.
- Is it mechanically decidable? These reduce to comparing file listings and date strings.
- Has it actually broken? Guard real incidents, not hypothetical risks.
What we left out is equally clear. "The English version shouldn't read like machine translation" is a real rule with no possible automated check. That kind stays in the document, reviewed by a person.
For borderline cases, the tie-breaker is how long it takes to notice. Errors you see immediately don't need a guard. Errors you find weeks later in a search console do.
Cross those two axes — does it break silently and is it mechanically decidable — and you get a decision table.
The bottom row is the part people skip. Things that break loudly don't need a guard, because something already tells you. Guards aren't free, and one that re-catches what's already caught pays the cost for nothing.
The top-right cell is the uncomfortable one: breaks silently, can't be checked. Items there need a fixed point in the process where a human looks — which is why the rules file carries a separate "not caught by the build" list. The same split decides what belongs in a rules file at all: move the top-left into code, and leave only the top-right written down.
There's an item still sitting in the top-left. Whether staticPages.js matches the real page list breaks silently and is perfectly decidable. It isn't a guard yet purely on the third test — it hasn't actually broken. The day it does is the day it goes in.
7. Write Down the Gaps That Remain
Guards create their own trap: "the build passed, so it's fine."
So the rules file lists what the build does not catch, explicitly.
The second item catches us regularly. sitemap.xml fills in posts automatically from a collection, but non-post pages come from a hand-maintained list. Move a page and that list has to move with it — and the build has no idea. It's the next candidate to become a guard.
Keeping that list in the rules file rather than beside the guard code is deliberate. Anyone opening the guard already knows what it checks. The person who needs this list is someone finishing work and deciding whether it's done — and what they open is the rules file.
8. Common Questions
Is string comparison on dates really safe?
It's safe for as long as the format stays YYYY-MM-DD. Equal width with the largest unit first makes lexicographic order match chronological order. It breaks when formats mix — a single-digit 2026-8-5, or a date with a time appended. So the regex is pinned to /^date:\s*(\S+)\s*$/m, and anything shaped differently stands out before it's ever compared.
Isn't regex-scraping HTML links dangerous?
Generally, yes. It's fine here because the scope is narrow — same-locale post links only, in the single fixed form /en/posts/{slug}.html. What it misses is equally clear: a link inside a comment, or one assembled from a template variable, won't be seen. We don't write those, so it doesn't bite.
What if the check itself is wrong and blocks a good build?
That's the argument for keeping guards simple. There have been no false positives so far, but if one appears, you fix the guard — you don't add a way to turn it off. Build a switch and eventually something ships with that switch flipped.
How does this scale as rules accumulate?
Today it's three checks in one function. Beyond that it becomes a function per check, with violations collected and thrown together. The thing to preserve is the separation between reading files and judging them — however many checks there are, the files should still be read once.
What if the code lives in separate repositories rather than a monorepo?
Then the build can't see it. The deep link rule on this site is exactly that case: the web throws a string that iOS and Android parse, and the web repository's build can't read app code. A rule whose subject sits outside the build's field of view can't become a guard. Those go in the documentation and in a code comment, both.
Does this work outside Eleventy?
Only the name changes. All you need is one hook that runs before templates are processed. Astro has astro:build:start; Next has the front of the build script; where there's no hook at all, making npm run build mean node check.mjs && hugo gets you the same thing. The tool doesn't matter — sitting where the deploy path has to pass does.
9. In Summary
Sixty lines all told, and those sixty lines held far better than a page of documentation.
- Start with the rules that break silently. The loud ones already get caught.
- Check whole-set properties once, before the build.
- Keep validation code simple. Dates as strings mean no parser and no time zones.
- Make messages actionable, and batch the violations.
- State what the guards don't cover so a green build never means "everything was checked."
- Put it where nothing can route around it. For a deploy, that place is the build.
If you're starting fresh, settle this while you're deciding repository structure. Structure determines which rules can become guards later — split the repositories and some rules can never be guarded at all.
If you're bolding the same sentence in a rules file for the third time, that's a signal the rule shouldn't live in a document. Enforce what can be enforced, and write down only what can't.