/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // The compiled-in catalogue and the schema.org identity graph. Content is // code now; these assertions are the contract the shop pages rely on, // checked against the actual shipped data. import std; import Catcrafts.Shared; using namespace Catcrafts; namespace { int failures = 0; void Check(bool ok, std::string_view what, std::string_view got = {}) { if (ok) return; ++failures; std::println(std::cerr, "FAIL: {}{}{}", what, got.empty() ? "" : " got: ", got); } // ── the compiled-in catalogue ───────────────────────────────────────── void CatalogueContract() { using namespace Catcrafts::Money; const auto& products = Content::Products(); // Three entries: the two phones, and the donation item that soft-opens // the shop. The Gen. 6 stays FIRST — it is the headline, and the suites // below address Products()[0] as the priced product — and the donation // stays LAST. Check(products.size() == 3, "content: three products"); if (products.size() == 3) { const Product& pr = products[0]; Check(pr.slug == "fp6-pmos", "content: product slug"); // Coming-soon is the pre-launch state; launch flips it to // "available" and this check keeps passing either way. Check(pr.Buyable() || pr.ComingSoon(), "content: product is buyable or deliberately coming soon"); Check(pr.variants.size() == 3, "content: three colours"); // Cost-plus pricing, derived in code: supplier + €60.50 gross markup, // exactly — the gross-up of the €50 Catcrafts keeps after VAT. The // margin identity itself (net of retail = net of supplier + 5000) is // asserted below; these pin the resulting stickers. Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 57380, "content: green = 513.30 supplier + 60.50 gross markup"); Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 57980, "content: black = 519.30 supplier + 60.50 gross markup"); Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 66538, "content: white = 604.88 supplier + 60.50 gross markup"); Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null"); Check(pr.priceInclMinor == 57380, "content: from-price is the cheapest variant"); // The pricing rule as the user states it: after shipping (a pass- // through) and VAT, every unit sold walks away with €50.00 — however // the supplier moves. Checked against the COMPILED catalogue, per // variant, in the same arithmetic the invoice and checkout use: // net(retail) - net(supplier) must be exactly 5000 minor. Shipping // has its own round-trip guarantee in ShouldComputeMoney, and // Mollie's per-transaction fee is the one accepted deviation. for (const auto& [slug, supplier] : std::initializer_list>{ { "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) { const Variant* v = pr.FindVariant(slug); Check(v && Money::NetFromGross(v->priceInclMinor) - Money::NetFromGross(supplier) == 5000, "content: variant nets the supplier price plus exactly €50", slug); } Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green", "content: cheapest is green"); // The boxed weight of one outgoing parcel. Not decoration: this // single integer picks the carrier's weight bracket, so it decides // the shipping cents added to every order AND the per-country // quantity ceiling. Regressed to 0 or off by an order of magnitude, // the shop quotes a rate the carrier does not honour on real parcels. Check(pr.shipWeightGrams == 700, "content: one boxed unit weighs 700 g"); { // A ladder straddling that weight. 700 g fits both bands, and // RateFor takes the CHEAPEST band that can carry it — 895, not // the tighter-looking 5500 — while the ceiling comes off the // heaviest band: 10000 / 700 = 14 units to a parcel. const std::vector ladder{ { 2000, 895 }, { 10000, 5500 } }; Check(RateFor(ladder, pr.shipWeightGrams) == 895, "content: the shipped weight lands in the cheap carrier bracket"); Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14, "content: and caps one parcel at fourteen units"); } // This used to assert the emergency-calling caveat was present. It // came off on 2026-08-18, when imsd called 112 over a live network in // an approved test session, so the assertion inverts: the page must // not go back to telling buyers that path is unverified. A future // safety claim may fill this field again — it just cannot be that one. Check(pr.safetyNote.find("not yet verified") == std::string::npos, "content: no stale unverified-emergency-calling caveat"); Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100, "content: warranty is written, not a placeholder"); // The product page's schema.org record must parse with our own // JSON parser and carry one variant Product per colour, each // with its ONE offer — built from the same integers the // checkout charges. { // The listing's shipping block is now carrier data, so the // render needs a table. Three of these four are priced on purpose // and must still not be advertised: US is refused on insurance, DE // and GB on their missing producer registrations. The carrier will // happily quote all three, which is exactly why the filter is worth // asserting — CH is the only one here besides home that sells. const std::vector feedTable{ { "NL", { { 2000, 895 } } }, { "CH", { { 2000, 2450 } } }, { "DE", { { 2000, 995 } } }, { "GB", { { 2000, 3300 } } }, { "US", { { 2000, 1794 } } }, }; auto pp = Views::RenderProduct(pr, Rates{}, feedTable); auto ld = Json::Parse(pp.meta.jsonLd); bool variantsOk = false; if (ld && ld->IsObject()) { if (const Json::Value* v = ld->Find("hasVariant"); v && v->IsArray() && v->array.size() == pr.variants.size()) { variantsOk = true; for (const Json::Value& node : v->array) { const Json::Value* o = node.Find("offers"); variantsOk = variantsOk && node.Str("@type") == "Product" && o && o->IsObject(); } } } Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk, "schema: product JSON-LD parses, one variant per colour"); // Merchant-grade fields: shipping, returns, sku, group id — // what Merchant Center's website-crawl feed reads at launch // (productGroupID is its item_group_id). Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos && pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos && pp.meta.jsonLd.find("\"sku\"") != std::string::npos && pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos, "schema: variants carry shipping, returns, sku and group id"); // The published rates ARE the carrier's, at one unit's weight. Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos && pp.meta.jsonLd.find("\"24.50\"") != std::string::npos, "schema: shipping rates come from the carrier table"); // And nothing the shop refuses is advertised, whatever the carrier // quotes for it — by rate, so a filter that dropped the country code // but kept the price would still be caught. Check(pp.meta.jsonLd.find("\"9.95\"") == std::string::npos && pp.meta.jsonLd.find("\"33.00\"") == std::string::npos && pp.meta.jsonLd.find("\"17.94\"") == std::string::npos, "schema: refused destinations are never advertised"); Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos && pp.meta.jsonLd.find("\"US\"") == std::string::npos, "schema: a refused destination is never advertised, priced or not"); // No table: no shipping claim. The listing loses the merchant // block rather than inventing a rate — the whole point of // dropping the zone fallback. auto bare = Views::RenderProduct(pr, Rates{}); Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos && bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos, "schema: with no carrier table the offer publishes no shipping"); Check(Json::Parse(bare.meta.jsonLd).has_value() && bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos, "schema: and the rest of the record still parses"); } // The Gen. 6+: the same contract as the phone above, asserted against // its own numbers. That the two SHARE the pricing rule is the point — // a second device must not quietly become a second pricing policy. const Product& plus = products[1]; Check(plus.slug == "fp6plus-pmos", "content: the refresh is the second listing"); Check(plus.brand == "Fairphone", "content: the refresh names its manufacturer"); Check(plus.Buyable() || plus.ComingSoon(), "content: the refresh is buyable or deliberately coming soon"); Check(plus.variants.size() == 3, "content: three colours on the refresh"); // Launch price in every colour, so all three stickers are €709.50 — // and each still nets exactly €50 after VAT, by the same arithmetic // the invoice and the checkout use. for (std::string_view slug : { "green", "black", "blue" }) { const Variant* v = plus.FindVariant(slug); Check(v && v->priceInclMinor == 70950, "content: refresh colour = 649.00 supplier + 60.50 gross markup", slug); Check(v && Money::NetFromGross(v->priceInclMinor) - Money::NetFromGross(64900) == 5000, "content: refresh colour nets the supplier price plus exactly €50", slug); } Check(plus.FindVariant("white") == nullptr, "content: a colour this model doesn't ship in is null"); Check(plus.CheapestVariant() && plus.priceInclMinor == plus.CheapestVariant()->priceInclMinor, "content: refresh from-price is the cheapest variant"); // Same box, same 193 g device, so the same bracket and the same // per-parcel ceiling the phone above is checked against. Check(plus.shipWeightGrams == 700, "content: the refresh leaves in the same 700 g parcel"); // One warranty text for both phones, not two that can drift — the // reason it is a shared constant rather than a second paste. Check(plus.warranty == pr.warranty, "content: both phones carry the identical warranty text"); Check(plus.specs.size() == pr.specs.size(), "content: the refresh states every spec row the Gen. 6 does"); // The refresh IS the faster silicon — if these two rows ever match the // Gen. 6's, the listing is selling the wrong phone. { bool soc = false, ram = false; for (const Spec& s : plus.specs) { soc = soc || (s.label == "Processor" && s.value.find("7s Gen 4") != std::string::npos); ram = ram || (s.label == "Memory" && s.value.find("12 GB") != std::string::npos); } Check(soc && ram, "content: the refresh lists its own SoC and 12 GB of RAM"); } // Its own photo, not the Gen. 6's — the two phones ship in different // colours, so sharing one render would picture a phone this listing // cannot sell. Published, not merely committed: a file that the page, // the link preview and the schema record all omit is a photo nobody // ever sees, which is the failure the checks below actually catch. Check(plus.image == "/fp6plus-pmos.jpg", "content: the refresh names its own photo"); Check(plus.image != pr.image, "content: and not the Gen. 6's"); // The pictured colour is the pre-selected one. With a single price // across the range that is purely a question of list order, so it is // worth pinning: a reorder would leave the page showing blue and // selling green by default. Check(plus.CheapestVariant() && plus.CheapestVariant()->slug == "blue", "content: the default colour is the one the photo shows"); { const auto pp = Views::RenderProduct(plus, Rates{}); // The spec lede is generated from the brand, so it must introduce // THIS sheet — the old markup hardcoded "Fairphone (Gen. 6)" and // would now caption the refresh's specs with the other phone's name. Check(pp.main.View().find("a stock Fairphone, unmodified") != std::string_view::npos, "refresh page: the spec lede names the brand, not a model"); Check(pp.main.View().find("stock Fairphone (Gen. 6)") == std::string_view::npos, "refresh page: and does not caption these specs as the Gen. 6's"); Check(pp.main.View().find("product__photo") != std::string_view::npos, "refresh page: the photo renders"); Check(pp.meta.ogImage == "/fp6plus-pmos.jpg", "refresh page: the link preview names its own photo"); Check(pp.meta.jsonLd.find("\"image\":\"https://catcrafts.net/fp6plus-pmos.jpg\"") != std::string::npos, "refresh page: and the schema record carries it absolute"); } // The donation item: the shop's soft opening. Available (it is what // the shop is open FOR) while both phones stay coming-soon; buyer // names the amount, so no price, no variants, no weight — and the // Buyable() price check is waived for exactly this shape. const Product& don = products[2]; Check(don.slug == "donation" && don.donation, "content: the donation item comes last"); Check(don.Buyable() && !don.ComingSoon(), "content: the donation item is on sale while the phones are not"); Check(don.priceInclMinor == 0 && don.variants.empty() && don.shipWeightGrams == 0, "content: a donation has no price, no colours and no parcel"); Check(don.warranty.empty() && don.specs.empty(), "content: a donation carries no spec sheet and no warranty"); // Its page: a donation form (amount + optional email), no price line, // no product JSON-LD — an offer with no amount is a claim shopping // crawlers can only misread — and no Fairphone sections. { const auto dp = Views::RenderProduct(don, Rates{}); const std::string_view html = dp.main.View(); Check(html.find("name=\"amount\"") != std::string_view::npos, "donation page: the form asks for an amount"); Check(html.find("name=\"street\"") == std::string_view::npos && html.find("name=\"country\"") == std::string_view::npos, "donation page: no address is asked for — nothing ships"); Check(html.find("field__req") == std::string_view::npos || html.find("Email , unmodified", so a spec sheet with no // brand renders a sentence with a hole in it. Cheaper to assert than to // branch around in the renderer. for (const Product& any : products) { Check(any.specs.empty() || !any.brand.empty(), "content: a product with a spec sheet names its manufacturer", any.slug); } Check(!Content::Projects().empty(), "content: projects present"); Check(Content::LegalPages().size() == 3, "content: three legal pages"); Check(Content::AboutPage().sections.size() >= 3 && !Content::AboutPage().sections[0].body.empty() && Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf") != std::string::npos, "content: about page names the founder"); Check(!Content::Demos().empty(), "content: demos present"); } // ── the identity graph ──────────────────────────────────────────────── // "Catcrafts" is two common words with no space, so it competes with a // decade of kids' craft blogs, Etsy and a Minecraft server on the // singular domain. The way out is not prose: it is one registered entity // that every page points at by @id. Those joins are worth asserting // because breaking one is silent — a typo'd @id still renders, still // validates as JSON-LD, and still splits the graph back into three // same-named strangers, which is the exact failure this markup exists to // prevent. void IdentityGraph() { constexpr std::string_view kOrgId = "https://catcrafts.net/#organization"; auto home = Views::RenderHome(Content::Projects(), std::span{}); auto ld = Json::Parse(home.meta.jsonLd); const Json::Value* org = nullptr; const Json::Value* site = nullptr; if (ld && ld->IsObject()) { if (const Json::Value* g = ld->Find("@graph"); g && g->IsArray()) { for (const Json::Value& node : g->array) { if (node.Str("@type") == "Organization") org = &node; if (node.Str("@type") == "WebSite") site = &node; } } } Check(org && site, "schema: home graph parses, carries Organization and WebSite"); if (org && site) { Check(org->Str("@id") == kOrgId, "schema: organization node is identified"); Check(site->Str("@id") == "https://catcrafts.net/#website", "schema: website node is identified"); // The join that makes two nodes one entity rather than two. const Json::Value* publisher = site->Find("publisher"); Check(publisher && publisher->IsObject() && publisher->Str("@id") == kOrgId, "schema: website is published by the organization node"); // The navigational query "catcrafts" is answered from the site // entity, so the spellings people type belong on it. const Json::Value* alt = site->Find("alternateName"); Check(alt && alt->IsArray() && !alt->array.empty(), "schema: website carries the spellings people type"); // Registry numbers, typed. These are the part no name-twin can // produce — each one is checkable against a public register, // which also means a wrong value is worse than no value. bool kvk = false, vat = false, eori = false; if (const Json::Value* ids = org->Find("identifier"); ids && ids->IsArray()) { for (const Json::Value& id : ids->array) { if (id.Str("propertyID") == "KVK") kvk = id.Str("value") == "78437059"; if (id.Str("propertyID") == "VAT") vat = id.Str("value") == "NL003329281B38"; if (id.Str("propertyID") == "EORI") eori = id.Str("value") == "NL1900095326"; } } Check(kvk && vat && eori, "schema: KVK, VAT and EORI present and exact"); // The name-twin guard. "Cat Crafts" with a space is the generic // craft phrase owned by everyone else; claiming it as an alternate // name argues for merging this entity into the corpus it needs to // stay distinct from. Check(home.meta.jsonLd.find("Cat Crafts") == std::string::npos, "schema: the spaced generic is not claimed as a brand name"); } // Cross-page joins: both must name the SAME @id the home page defines. auto about = Views::RenderAbout(Content::AboutPage()); Check(about.meta.jsonLd.find(kOrgId) != std::string::npos, "schema: about joins the founder to the organization node"); if (!Content::Products().empty()) { auto pp = Views::RenderProduct(Content::Products()[0], Rates{}); Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos, "schema: offers are sold by the organization node"); } // The person join, same mechanism in the other direction: home's // founder and about's mainEntity must name one Person node, or "who // founded Catcrafts" splits into two same-named strangers too. constexpr std::string_view kPersonId = "https://catcrafts.net/about#person"; Check(home.meta.jsonLd.find(kPersonId) != std::string::npos && about.meta.jsonLd.find(kPersonId) != std::string::npos, "schema: founder and about name one Person node"); } // ── the sale gates, built by hand ───────────────────────────────────── // The shipped catalogue is one product in one status with three colours, so // asserting against it can only ever exercise one arm of each guard. These // products exist to reach the others. // // Buyable() is the ONLY server-side gate on POST /checkout, and it is a // conjunction: status AND a price. ComingSoon() is separately what decides // whether a visitor is told "the shop has not opened yet" or "temporarily // unavailable", so the two are pinned apart rather than as one either/or. void SaleGates() { Product priced; priced.status = "available"; priced.priceInclMinor = 56330; Check(priced.Buyable() && !priced.ComingSoon(), "status: available with a price is the one buyable state"); // The half the catalogue can never exercise. An "available" product with // no price is what a from-price sync that failed to run leaves behind // (Content::Products derives priceInclMinor from CheapestVariant); if // this arm of the conjunction regressed, checkout would mint a real order // record and a live payment link for €0. Product unpriced; unpriced.status = "available"; unpriced.priceInclMinor = 0; Check(!unpriced.Buyable(), "status: an unpriced product cannot be bought"); // The donation arm of the same conjunction: no price by definition, yet // buyable — and ONLY because the flag says the buyer names the amount. // The status half still gates it like anything else. Product gift; gift.status = "available"; gift.donation = true; Check(gift.Buyable(), "status: an available donation needs no price"); gift.status = "unavailable"; Check(!gift.Buyable(), "status: a withdrawn donation is not buyable either"); Product withdrawn; withdrawn.status = "unavailable"; withdrawn.priceInclMinor = 56330; Check(!withdrawn.Buyable() && !withdrawn.ComingSoon(), "status: unavailable is neither for sale nor coming soon"); Product soon; soon.status = "coming-soon"; soon.priceInclMinor = 56330; Check(soon.ComingSoon() && !soon.Buyable(), "status: coming-soon publishes a price without opening orders"); // A product with no colours is a supported catalogue shape — priceInclMinor // is then simply the price. The null return is the only thing standing // between that shape and the two call sites that dereference the result // (the checkout form's default selection, and the checkout handler). Product plain; plain.priceInclMinor = 56330; Check(plain.CheapestVariant() == nullptr, "variants: no colours means no cheapest colour"); Check(plain.FindVariant("green") == nullptr && plain.FindVariant("") == nullptr, "variants: nothing is ever found in an empty colour list"); // The loop compares with a strict <, so a tie keeps the listed order. // That is what stops the advertised "from" price and the form's default // selection from naming different colours when two cost the same. Product tied; tied.priceInclMinor = 56330; tied.variants = { { "green", "Forest Green", 56330 }, { "black", "Black", 56330 }, }; Check(tied.CheapestVariant() && tied.CheapestVariant()->slug == "green", "variants: equally priced colours keep the listed order"); } // ── the product page's live-total blob ──────────────────────────────── // RenderCheckoutForm renders only for a Buyable product, and the shipped // catalogue is coming-soon, so today nothing renders it: the two suites that // read this attribute both sit behind a ShopOpen() gate. Flip a copy to // "available" and read the markup here instead — the refusal lists in that // blob are what make the on-page total decline in exactly the places // checkout declines, and a page that quotes a total for a sanctioned or // no-sale destination invites an order that must then be refused. void CheckoutPreviewData() { if (Content::Products().empty()) return; Product pr = Content::Products()[0]; pr.status = "available"; Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders"); const std::vector feedTable{ { "NL", { { 2000, 895 } } }, { "DE", { { 2000, 995 } } }, }; const auto page = Views::RenderProduct(pr, Rates{}, feedTable, {}, {}, true); const std::string_view html = page.main.View(); // The blob is a JSON document inside an HTML attribute, so every quote // arrives escaped — matching the escaped form is matching what the // browser actually parses back out. Check(html.find(""s":["RU","BY","KP"]") != std::string_view::npos, "checkout: the preview carries the sanctions list verbatim"); // `w` is the shipping ALLOW-list: the preview refuses a country by its // ABSENCE here, which is why the payload stays five codes long instead of // enumerating the two hundred that are closed. Check(html.find(""w":["NL","CH","AU"," ""HK","SG","RS","ME"," ""AL","XK","GE"]") != std::string_view::npos, "checkout: the preview carries the shipping allow-list verbatim"); Check(html.find(""US"") == std::string_view::npos, "checkout: no deny-list survives in the preview payload"); // Both refusals ship their own sentence, or the preview would word a decline // differently from the submit that follows it. Check(html.find(""sm"") != std::string_view::npos && html.find(""rm"") != std::string_view::npos, "checkout: the preview carries a message for each refusal"); // The unit weight, which is what selects a bracket out of the carrier // table the same blob carries. Check(html.find(""g":700") != std::string_view::npos, "checkout: the preview knows one unit's shipping weight"); // Per-colour unit prices: the preview multiplies these, so they are the // same integers the checkout charges or the two disagree on screen. Check(html.find(""green":57380") != std::string_view::npos && html.find(""black":57980") != std::string_view::npos && html.find(""white":66538") != std::string_view::npos, "checkout: every colour is priced in the preview blob"); } // ── which euro amount a localised price converts ────────────────────── // The headline price rides along as one pre-formatted attribute per // currency, and the basis differs by EU membership: a member's currency // converts the VAT-inclusive price the buyer pays, everyone else's converts // the ex-VAT export price. Invert that test and a Swedish visitor sees a // figure 21% below what their card is charged, or a British one 21% above // the export price — both while every existing assertion (which only checks // that the attributes exist) still passes. void LocalisedPriceBasis() { // €121.00 inclusive: net = 12100 * 10000 / 12100 = 10000 exactly, so the // two bases are a clean €121 and €100 with no rounding to reason about. Product pr; pr.slug = "basis-probe"; pr.name = "Basis probe"; pr.status = "coming-soon"; pr.priceInclMinor = 12100; // 1 EUR = 1 unit in both currencies, so the printed number can only // report which euro amount the conversion started from. Rates rates; rates.date = "2026-01-01"; rates.microPerEur.emplace_back("SEK", 1'000'000); rates.microPerEur.emplace_back("GBP", 1'000'000); const auto page = Views::RenderProduct(pr, rates); const std::string_view html = page.main.View(); // SE is an EU member: base 12100, converted whole and half-up -> // (12100 * 1e6 + 5e7) / 1e8 = 121. Check(html.find(R"(data-sek="~kr 121")") != std::string_view::npos, "price: an EU member's currency converts the VAT-inclusive price"); // GB is not: base 10000 -> 100. Check(html.find(R"(data-gbp="~£100")") != std::string_view::npos, "price: a non-EU currency converts the ex-VAT export price"); // The euro fallback that no-JS visitors and crawlers read is the export // price, in the same units the two above were derived from. Check(html.find(R"(data-world="€100")") != std::string_view::npos, "price: the export euro price is the world default"); } // ── the URLs this site advertises as its own ────────────────────────── // The sitemap is the list of URLs the site asks crawlers to index; the nav // is what a visitor clicks. Both are hand-maintained lists, so a renamed // legal slug or a retired path left behind advertises a 404 — or a 301 — as // canonical, silently and forever. Nothing else cross-checks them: the route // suite walks a literal list of its own rather than SitemapPaths(). void AdvertisedUrls() { Views::SiteContent site; site.legal = Content::LegalPages(); for (const std::string_view path : SitemapPaths()) { const Route r = ParseRoute(path); Check(r.kind != RouteKind::NotFound, "sitemap: every advertised path resolves to a page", path); // A route carrying a canonical target is a redirect whatever it // renders, and asking a crawler to index a redirect is asking it to // index a non-canonical URL. Check(r.canonicalRedirect.empty(), "sitemap: no advertised path is itself a redirect", path); // Parsing only proves the URL is SHAPED like a legal page; the // dispatcher's lookup is what decides whether it renders one. if (r.kind == RouteKind::Legal) { Check(site.FindLegal(r.slug) != nullptr, "sitemap: every advertised legal slug names a real page", path); } } for (const NavItem& item : NavItems()) { Check(ParseRoute(item.href).kind == item.kind, "nav: every nav entry parses to the route it claims", item.href); } } } // namespace int main() { CatalogueContract(); IdentityGraph(); SaleGates(); CheckoutPreviewData(); LocalisedPriceBasis(); AdvertisedUrls(); if (failures != 0) { std::println(std::cerr, "{} check(s) failed", failures); return 1; } return 0; }