Back to Ideas 7 min read

A Map Drawn by the Build

E
EkoHacks Team
·

Day zero said thirty six of our sixty two routes were unknown to Google. Not crawled and rejected, not crawled and kept: never visited. They carried no crawl date. Every landing page except the home page was among them, along with twenty seven posts. The previous post gave each page one address. This one tells the crawler the addresses exist.

Why a list, and why not by hand

A sitemap is a file that says: here are the pages. It is the simplest thing on the site, and it is where sites lie most, because the usual way to make one is to write it, or to install something that writes it from its own idea of what the pages are, and then nobody updates it. A page is added and the map does not know. A page is removed and the map still points at it. Within a week the map and the site disagree, and a crawler has no way to tell which of them is wrong.

The reframe is the one the first post of the series set up. The sitemap is derived state. It should be computed from the thing it describes, every time that thing is built, by the process that builds it. If the build is the only thing that decides which pages exist, the build is the only thing that can draw an honest map of them.

We already had the list. The second post in this series showed it: a directory read at build time turns every markdown file into a route, and the prerenderer renders each one. What we needed was to take the prerenderer's own output, the pages it actually wrote, and hand it back as a map.

The change

The prerenderer fires an event when it has finished, with every route it rendered and the file it wrote for each. The sitemap is drawn from that:

export function sitemapEntries(routes: PrerenderedRoute[], rootDir: string) {
  const seen = new Set<string>();
  const entries: { loc: string; lastmod?: string }[] = [];
  for (const r of routes) {
    if (r.error || r.skip) continue;
    if (!r.fileName?.endsWith(".html")) continue;
    if (NOINDEX.has(r.route)) continue;
    const loc = canonicalUrl(r.route);
    if (seen.has(loc)) continue;
    seen.add(loc);
    entries.push({ loc, lastmod: lastmod(r.route, rootDir) });
  }
  return entries.sort((a, b) => a.loc.localeCompare(b.loc));
}

Keep the routes that became HTML, drop the one page we mark as not for crawlers, give each the same canonical address the page itself declares, and sort. The function that makes the address is the same function the canonical tag uses, moved into a shared file so the map and the page cannot disagree about what a page is called. The hook that runs it is four lines in the build configuration:

"nitro:init"(nitro) {
  nitro.hooks.hook("prerender:done", ({ prerenderedRoutes }) => {
    const count = writeSitemap(nitro.options.output.publicDir, prerenderedRoutes, rootDir);
    nitro.logger.info(`Sitemap written with ${count} pages`);
  });
},

Posts and legal pages carry a last modified date read from their own file, the date in the frontmatter for posts and the updated field for legal pages. It is the date we wrote down, which is the honest version until a later post makes it the date the file actually changed.

robots.txt, which had been one byte, is now five lines: allow everything, keep the styleguide out, and point at the map. And the live check from the previous post grew two more questions: does the live sitemap list exactly the pages the source tree says are published, no more and no fewer, and does robots.txt point at it.

What the map found

The first build wrote a map of sixty six pages. We expected sixty five.

The extra one was a post marked draft: true in July, the last part of an eight part series, held back on purpose. The listing hides drafts, so no card, no link, nothing a reader could click. But the prerenderer enumerates files, not listings, and it had been rendering that post on every build since July. The page was live at its address, Google had crawled it and indexed it, and now the map, being honest, listed it.

This is what a derived map is for. A hand written map would have omitted the draft because the person writing it knew it was a draft, and the page would have stayed live and indexed with nobody the wiser. The build's map listed what the build built, and what the build built was wrong.

The fix went into the one list everything else derives from. The enumeration now reads each file's frontmatter and leaves drafts out, so a draft is not prerendered, is not on the map, and is a 404 in production. The post page refuses drafts too, so the development server says the same thing. One page will drop out of Google's index because of this, and it should.

Sixty five pages on the map. The live check, run against production before the deploy, said what we expected:

https://ekohacks.com: 70 addresses checked, 4 wrong
  /sitemap.xml answered 404
  robots.txt does not point at https://ekohacks.com/sitemap.xml
  ...

Telling the crawler

A map nobody reads is a file. Search Console accepts a sitemap through its API, the same one we pulled the day zero numbers from, so the submission is a command we can run again rather than a button we pressed once:

PUT sites/sc-domain:ekohacks.com/sitemaps/https://ekohacks.com/sitemap.xml
204

{
  "path": "https://ekohacks.com/sitemap.xml",
  "lastSubmitted": "2026-08-22T13:57:00Z",
  "lastDownloaded": "2026-08-22T13:57:01Z",
  "warnings": "0",
  "errors": "0",
  "contents": [{ "type": "web", "submitted": "65", "indexed": "0" }]
}

One second after we submitted it, Google had read it: sixty five addresses, no warnings, no errors, and an indexed count of zero, which is the honest starting line. Indexing follows crawling, and crawling is on Google's schedule, not ours. That counter is one of the numbers the last post of this series will come back to.

The first attempt at the submission came back 403, because the credentials we had made for reading the day zero numbers were read only, and submitting a sitemap is a write. We made new ones with the wider scope. We mention it because the series is supposed to show the work, and the work included a permissions error.

The live check, run once more after the deploy, now asks seventy one questions of the site and gets the right answer to all of them, except for the address of this post, which did not exist when the check ran.

The number for today: sixty five pages on the map, drawn by the build, one draft found by it.

E

Written by

EkoHacks Team

More from Ideas

·6 min read

What the Nullable Gave Back

One file, seven behaviours held fixed, the database swapped for a Nullable: about 180 times less time inside the tests, and coverage flat to two decimals.

E
EkoHacks Team
·6 min read

Twenty Six More Tests, Four Fewer Behaviours

Removing the mocks grew the suite from 44 tests to 70 and quietly deleted four behaviours, every one of them a failure path. Test count is not coverage.

E
EkoHacks Team
·6 min read

The Best Coverage Number in the Room

Same commit, same spec, same test count. The mocked suite ran 5.6 times faster, covered 3.5 fewer points of real code, and posted the best branch coverage.

E
EkoHacks Team