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.

1. Every post exists as a ko/en pair 2. Both files carry the same publish date 3. Links between posts always point backward in time

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.

The common thread: the repository is fine and only the deployed site is wrong.

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.

Location Runs when Skippable? Blocks deploy Setup ──────────────────────────────────────────────────────────────────────── Commit hook On commit yes no everyone (local only) (--no-verify) installs it CI workflow On push or PR no if configured workflow file + permissions Build hook On every build no yes none (local, CI, (one function deploy) in config)

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.

eleventyConfig.on("eleventy.before", () => { const slugs = (lang) => readdirSync(`src/${lang}/posts`) .filter((f) => f.endsWith(".njk") && f !== "index.njk") .map((f) => f.replace(/\.njk$/, "")) .sort(); const ko = slugs("ko"); const en = slugs("en"); const missingEn = ko.filter((s) => !en.includes(s)); const missingKo = en.filter((s) => !ko.includes(s)); if (missingEn.length || missingKo.length) { throw new Error([ "ko/en posts are not symmetric.", ...missingEn.map((s) => ` missing en: ${s}`), ...missingKo.map((s) => ` missing ko: ${s}`), ].join("\n")); } // ... further checks follow });

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.

const posts = new Map(); for (const lang of ["ko", "en"]) { for (const slug of ko) { // symmetry already verified const raw = readFileSync(`src/${lang}/posts/${slug}.njk`, "utf8"); posts.set(`${lang}/${slug}`, { lang, raw, date: raw.match(/^date:\s*(\S+)\s*$/m)?.[1] ?? null, }); } }

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.

Validation code should be simpler than what it validates.

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.

At the time of writing: 59 posts × 2 locales = 118 files readdirSync 2 calls readFileSync 118 calls, ~2.0MB total regex match 118 (date) + 118 (every link in the body) Of a ~0.5s full build, the guard is tens of milliseconds

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.

// publish dates must agree const mismatched = ko .map((slug) => ({ slug, ko: posts.get(`ko/${slug}`).date, en: posts.get(`en/${slug}`).date, })) .filter(({ ko: k, en: e }) => k !== e); // link direction — scrape every post link in every body const dead = []; for (const [key, { lang, raw, date }] of posts) { const pattern = new RegExp(`href="/${lang}/posts/([a-z0-9-]+)\\.html"`, "g"); for (const [, target] of raw.matchAll(pattern)) { const linked = posts.get(`${lang}/${target}`); if (!linked) { dead.push(` ${key} → ${target}: no such post`); } else if (linked.date > date) { dead.push(` ${key}(${date}) → ${target}(${linked.date}): target publishes later`); } } }

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.

Bad — no idea what to do about it Error: Invalid post configuration Good — the next action is decided on reading it Error: ko/en posts disagree on date. Publish times will diverge. zone2-cardio-heart-rate-guide: ko=2026-08-27 en=2026-08-29

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.

Element Example If it's missing ────────────────────────────────────────────────────────────────────── What "ko/en posts disagree on date" Which rule? Unclear Where "zone2-cardio-heart-rate-guide" You go hunting for it The values "ko=2026-08-27 en=2026-08-29" Which side is wrong? Why "Publish times will diverge" Fixed, but unconvinced

"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.

Kind Frequency Typical cause ──────────────────────────────────────────────────────────────────── Wrote ko, never wrote en most common Stopped partway through a set Link target publishes later next Recent posts linking each other Dates disagree rare Copied a post, fixed one date

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.

Mechanically decidable Not decidable ────────────────────────────────────────────────────────────────────── Breaks silently Move it into a guard Document + a review step (ko/en symmetry, date (English quality, agreement, link deep link behavior on direction) a real device) Breaks loudly Leave it Leave it (template syntax errors — (broken layout — the build already dies) you see it on open)

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.

Caught by the build: locale symmetry, date agreement, links between posts, template syntax errors NOT caught — check these yourself: whether layout and links survive in a browser whether staticPages.js matches the real page list whether relative paths in passthrough .html match depth deep link fallback behavior (needs a real device)

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.