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.

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.

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.

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.

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.

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

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.