Build1 publisher3 min readPublished
Three hand-written route lists put /about in the sitemap and behind a login gate
Eight hand-kept lists sat behind every new page on pub-trivia.app. One typed array now feeds the sitemap, breadcrumbs and footer, with unit tests that fail the commit when a description runs long.
The Engineer · Build desk

What happened
- The site kept three route lists written independently of one another: the middleware auth allowlist, the sitemap, and robots.txt.
- Every page-level list is now derived from one array of typed ContentNode records holding path, title, heading, description, cluster, updated and related.
- Plain unit tests over the array run in milliseconds and cover duplicate paths and titles, description and title lengths, date format and self-links.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint One type is now the schema for the whole site, so a page that needs a field ContentNode does not have is a type change and a test change before anyone writes copy.
- capability Dead links are found by a test over an in-memory array, so that check needs no crawler, no staging deploy and no wait for a recrawl.
- decision The title-length test reproduces the layout template's site-name suffix, so whoever changes that template is also changing the test that guards it.
- constraint The no-orphans invariant decided where navigation goes: the hubs sit in the footer so that every page counts as linked from somewhere.
Three lists can disagree in three ways, because three items make three pairs [23]. The post counts eight hand-kept lists behind every new marketing page [2], and across about seventy pages that comes to 560 entries a person types [24]. The failure it reports happened when the site had eight pages: /about was in the sitemap while the middleware auth gate redirected every crawler that followed the link to /login [4].
As a general rule about drift, the evidence here is one incident in one repo [25]. The author wrote that "at seventy the lists do not disagree if you are careful, they disagree because you are human" [22].
The registry is a TypeScript type with eight fields, from path through related [5]. Two of them carry most of the benefit. description is used twice, as the meta description and as the line shown under the page's name anywhere else on the site that links to it [6]. One string, so the footer teaser and the page's own head tag cannot drift apart. breadcrumbFor() feeds both the visible trail and the BreadcrumbList JSON-LD [10]. outboundLinksFor() unions the breadcrumb ancestors, the spokes of a hub, and whatever a human put in related, then deletes the page's own path [11]. The upward link to the hub is never hand-written, because the breadcrumb already supplies it [12].
The sitemap used to stamp every URL with the build date, which told crawlers the terms of service had been revised this morning, every morning [8]. A lastmod that always says now is worse than no lastmod at all, the author wrote, because it is a signal you have deliberately made meaningless [9]. updated is now a field someone types, bumped when the words change and not when the file is reformatted [7], and a test parses it as YYYY-MM-DD because a typo goes straight into the sitemap [17].
The invariants are ordinary unit tests over the array, and they run in milliseconds [13]. Two of them cost something. The rendered-title check has to model the layout template appending the site name [16], so that append rule now exists in the template and in the test, and editing one breaks the other. The no-orphans check counts the header and footer as inbound links, which is why the topic hubs sit in the footer [19].
For any of this to transfer, the pages have to fit the fields. A route that needs something ContentNode does not have is a type change and a test change before it is a page. The content also has to be what the build reads: if an editor can publish after the tests ran, the no-duplicate-titles check [14] is guarding a file the live site no longer follows. The excerpt shows the sitemap, footer, breadcrumb component, JSON-LD and related-links block reading the registry, and exports INDEXABLE_CONTENT_PATHS as the list of indexable paths [21]. It does not include the middleware or robots.txt code. Those two were part of the original disagreement [3].
What to watch
- Whether a follow-up shows the middleware allowlist and robots.txt generated from INDEXABLE_CONTENT_PATHS, since those two were part of the original disagreement.
- What happens to the ContentNode type the first time a page needs a field it does not have, such as a price table or a second locale.
- Whether hand-typed updated dates change how often crawlers return, now that lastmod is no longer the build date.