/* 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 page renderers — the reason Catcrafts.Shared exists. // // Each returns SafeHtml for the inner content of
, plus the PageMeta the // document head needs. The wasm app feeds the markup to SetInnerHTML; the // native server will wrap it in RenderDocument and send it as the response // body. One template per page, so a crawler and the app can never be looking // at different markup. // // Every renderer is a pure function of its inputs. No I/O, no globals, no DOM. // That is what makes them testable on the host (see server/implementations) // rather than only observable in a browser tab. export module Catcrafts.Shared:Views; import std; import :Content; import :Html; import :Form; import :Model; import :Money; import :Route; namespace Catcrafts::Views { using Html::SafeHtml; using Html::Escape; using Html::Format; using Html::Num; using Html::Raw; using Html::Url; using Html::Attr; using Html::Join; export struct RenderedPage { PageMeta meta; SafeHtml main; // 404 for NotFound, 301 for a legacy redirect, 200 otherwise. The wasm app // ignores this; the server uses it as the response status. int status = 200; }; // ── chrome ──────────────────────────────────────────────────────────── // Nav is emitted as real links, not click-only elements. // // The previous version used `` with no href, because // Crafter.Graphics could not cancel a click and any real link would trigger a // full page load. That made the nav invisible to crawlers, un-middle-clickable // and unreachable by keyboard. The preventDefault support added to // AddClickListener is what lets these be honest links that the app upgrades to // client-side navigation. export SafeHtml RenderNav(RouteKind current) { std::vector items; for (const NavItem& item : NavItems()) { const bool active = item.kind == current; items.push_back(Format( R"(
  • {}
  • )", active ? Raw(" nav-link--active") : SafeHtml{}, Url("href", item.href), // aria-current is how a screen reader conveys "you are here"; // the active class alone is a purely visual signal. active ? Attr("aria-current", "page") : SafeHtml{}, Escape(item.label))); } // The mark: an ASCII cat drawn as paths — ^ ^ caret eyes, and the old ">_" // prompt kept as the mouth. Inline rather than an so it takes its // colours from the CSS variables (face = currentColor via .logo__mark, // prompt = --text) and costs no extra request. The underscore carries // class="logo-cursor", which styles.css blinks like a real terminal cursor // (and stops under prefers-reduced-motion). // // Same geometry as favicon.svg with hardcoded colours — change one, change // both, or the header and the tab icon drift apart. // // aria-label on the rather than text: on narrow viewports .logo__text // is display:none, which would leave the link with no accessible name. return Format( R"()", Url("href", "/"), Join(items)); } // Deliberately no link to the fediverse account. // // The account is used for plenty that has nothing to do with this work, so // pointing visitors at the profile would send them somewhere mostly irrelevant. // The posts page carries the individual posts instead, each linking to its own // thread — which is the part that is actually about the work. export SafeHtml RenderFooter() { return Format( R"()", Url("href", "https://forgejo.catcrafts.net/Catcrafts/"), Url("href", "https://forgejo.catcrafts.net/Catcrafts/catcrafts.net"), Url("href", "/legal/privacy"), Url("href", "/legal/terms"), Url("href", "/legal/imprint")); } // ── schema.org JSON-LD ──────────────────────────────────────────────── // // Machine-readable facts for crawlers and AI answer engines. This exists // because two name-twins (a Minecraft server on the singular domain, a cat // conservation program) kept absorbing the brand in search results and AI // overviews — one overview flatly asserted that nobody named Catcrafts sells // Fairphone hardware. Prose can be paraphrased away; typed identity and // offer data are what those systems quote. // JSON string escaping. Its own function rather than the HTML escaper // because the rules are different (backslash and quote, not ampersand). std::string JsonStr(std::string_view s) { std::string out; out.reserve(s.size() + 2); out += '"'; for (char c : s) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if (static_cast(c) < 0x20) { out += std::format("\\u{:04x}", static_cast(c)); } else { out += c; } } } out += '"'; return out; } // ── home ────────────────────────────────────────────────────────────── export RenderedPage RenderHome(std::span projects, std::span posts) { std::vector cards; for (const Project& p : projects) { if (!p.featured) continue; cards.push_back(Format( R"(
    )" R"(

    {}

    )" R"(

    {}

    )" R"(
    )", Url("href", p.url), Escape(p.name), Escape(p.blurb))); } std::vector recent; for (std::size_t i = 0; i < posts.size() && i < 3; ++i) { const Post& post = posts[i]; recent.push_back(Format( R"(
  • {}{}
  • )", Url("href", post.permalink), Escape(post.title), Escape(DateOnly(post.published)))); } SafeHtml main = Format( R"(
    )" // No visible headline on purpose: a hero slogan reads as marketing, // which is not the register this site wants. The h1 stays for the // document outline (screen readers, crawlers) but is visually hidden; // the mission text below is the first thing anyone sees. R"(

    Catcrafts

    )" R"(

    Catcrafts makes accessible, open-source software )" R"(and hardware for the benefit of everyone. The goal: a real alternative )" R"(to big tech that anyone can use, not just Linux nerds.

    )" R"(

    )" R"(Browse projects)" R"(Browse shop)" R"(

    )" // The person-company weld, human-readable edition. The about page // and the Organization founder record are the machine-readable one. R"()" R"(
    )" R"(
    )" R"(

    Featured work

    )" R"(
    {}
    )" R"(
    )" R"({})", Url("href", "/projects"), Url("href", "/shop"), Url("href", "/about"), cards.empty() ? Raw(R"(

    No featured projects yet.

    )") : Join(cards), recent.empty() ? SafeHtml{} : Format( R"(
    )" R"(

    Recent posts

    )" R"(
      {}
    )" R"(

    All posts

    )" R"(
    )", Join(recent), Url("href", "/posts"))); RenderedPage page; page.meta.title = "Catcrafts — open-source software and hardware"; page.meta.description = "Catcrafts makes accessible, open-source software and hardware: an " "alternative to big tech. A C++23 engine, build system and networking " "stack from scratch, and Linux with IMS/VoLTE on the Fairphone 6."; page.meta.canonical = "/"; // The identity record: name, registered VAT identity and the Forgejo // profile separate this Catcrafts from the name-twins by type and by // registration, not just by prose. All literal — nothing user-supplied. page.meta.jsonLd = R"({"@context":"https://schema.org","@type":"Organization",)" R"("name":"Catcrafts","url":"https://catcrafts.net/",)" R"("logo":"https://catcrafts.net/favicon.svg",)" R"("email":"info@catcrafts.net","vatID":"NL003329281B38",)" R"("founder":{"@type":"Person","name":"Jorijn van der Graaf",)" R"("url":"https://catcrafts.net/about"},)" R"("description":"Catcrafts makes accessible, open-source software and hardware: )" R"(the Crafter C++23 suite, the imsd IMS/VoLTE implementation, and Fairphone 6 )" R"(handsets sold with postmarketOS preinstalled.",)" R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]})"; page.main = std::move(main); return page; } // ── projects ────────────────────────────────────────────────────────── // A high-level overview on purpose: name, one sentence, a link. Each project's // Forgejo page is the canonical source for status, docs and detail — repeating // any of that here just gives it a second place to go stale. export RenderedPage RenderProjects(std::span projects) { std::vector cards; for (const Project& p : projects) { cards.push_back(Format( R"(
    )" R"(

    {}

    )" R"(

    {}

    )" R"({})" R"(
    )", Url("href", p.url), Escape(p.name), Escape(p.blurb), Escape(p.language))); } RenderedPage page; page.meta.title = "Projects — Catcrafts"; page.meta.description = "The Crafter suite: a C++23 build system, graphics engine, networking " "stack, and an IMS/VoLTE implementation for the Fairphone 6."; page.meta.canonical = "/projects"; page.main = Format( R"()" R"(
    {}
    )", Url("href", "https://forgejo.catcrafts.net/Catcrafts/"), cards.empty() ? Raw(R"(

    Project list unavailable.

    )") : Join(cards)); return page; } // ── posts ───────────────────────────────────────────────────────────── // The media a post carries — usually the point of the post rather than // decoration, since these are screen recordings of the work. // // Served from our own origin (tools/fetch-media.sh mirrors it), which is what // keeps the privacy notice's "everything comes from catcrafts.net" true and // stops every visitor's IP reaching whichever instance hosted the file. // // Video is preload="metadata", not "auto": a page with several 5 MB recordings // must not pull them all on load. Dimensions come from the content file so the // browser reserves the right box and nothing jumps as files arrive. SafeHtml RenderPostMedia(std::span media) { if (media.empty()) return SafeHtml{}; std::vector items; for (const PostMedia& m : media) { SafeHtml dims = (m.width > 0 && m.height > 0) ? Format("{}{}", Attr("width", std::to_string(m.width)), Attr("height", std::to_string(m.height))) : SafeHtml{}; if (m.kind == "video") { SafeHtml poster = m.poster.empty() ? SafeHtml{} : Url("poster", m.poster); items.push_back(Format( R"()", Url("src", m.src), poster, dims)); } else { // alt is empty and aria-hidden is absent on purpose: these are // screenshots whose meaning is already in the post title and // excerpt, and inventing descriptive alt text here would be making // up what the picture shows. items.push_back(Format( R"()", Url("src", m.src), dims)); } } return Format(R"(
    {}
    )", Join(items)); } export RenderedPage RenderPosts(std::span posts) { std::vector cards; for (const Post& p : posts) { // A link post gets a second, clearly-labelled outbound link. Without // the label the two links are indistinguishable and one of them // silently leaves the site. SafeHtml linkRow = p.linkUrl.empty() ? SafeHtml{} : Format( R"(

    {}

    )", Url("href", p.linkUrl), Escape(p.linkUrl)); cards.push_back(Format( R"()", Url("href", p.permalink), Escape(p.title), Attr("datetime", p.published), Escape(DateOnly(p.published)), Escape(p.community), p.excerpt.empty() ? SafeHtml{} : Format(R"(

    {}

    )", Escape(p.excerpt)), RenderPostMedia(p.media), linkRow, Num(p.score), Url("href", p.permalink), Num(p.comments))); } RenderedPage page; page.meta.title = "Posts — Catcrafts"; page.meta.description = "Posts from the fediverse; discussion happens there."; page.meta.canonical = "/posts"; page.main = Format( R"()" R"(
    {}
    )", cards.empty() ? Raw(R"(

    No posts loaded right now.

    )") : Join(cards)); return page; } // ── shop ────────────────────────────────────────────────────────────── // The product page's price block: the same single headline number as the shop // card (so a Canadian sees ~CA$776 at the top here too), plus one supporting // line per region. Both lines are always in the markup — which one shows is // CSS driven by the cc-eu/cc-noneu classes; without JS both show, which is // complete and honest. The HTML stays identical for every visitor. // // "21% VAT", not "21% EU VAT": VAT rates are per member state, and this is // the Dutch rate charged under the OSS distance-selling threshold. The terms // page spells out "Dutch VAT"; the price label stays plain. SafeHtml PriceSingle(const Product& p, const Rates& rates); SafeHtml RenderPriceLine(const Product& p, const Rates& rates) { const std::int64_t net = Money::NetFromGross(p.priceInclMinor); return Format( R"(

    )" R"({}{})" R"(

    )", p.variants.size() > 1 ? Raw(R"(from )") : SafeHtml{}, PriceSingle(p, rates), Escape(Money::FormatEuro(net))); } // The shop card's price: ONE easy number. The default text is the euro price // (what no-JS visitors and crawlers read); every supported currency rides // along as a pre-formatted data attribute, and the timezone hint script swaps // the text to the visitor's own. "~" marks a converted amount as approximate. // // The conversion basis differs by membership, not by currency whim: an EU // member's currency (SEK, PLN, …) converts the VAT-inclusive price the buyer // will actually pay; a non-EU currency converts the ex-VAT export price. All // arithmetic happens HERE, server-side, from the same integers the checkout // charges — the script only ever picks a string. SafeHtml PriceSingle(const Product& p, const Rates& rates) { const std::int64_t net = Money::NetFromGross(p.priceInclMinor); std::vector attrs; attrs.push_back(Attr("data-world", Money::FormatEuro(net))); for (const Money::CurrencyRow& row : Money::AllCurrencies()) { const std::int64_t rate = rates.Find(row.cur.code); if (rate <= 0) continue; const std::int64_t base = Money::IsEuCountry(row.cc) ? p.priceInclMinor : net; std::string name = "data-"; for (const char c : row.cur.code) name += static_cast(c - 'A' + 'a'); attrs.push_back(Attr(name, std::format( "~{}{}", row.cur.symbol, Money::ConvertIndicative(base, rate)))); } return Format( R"({})", Join(attrs), Escape(Money::FormatEuro(p.priceInclMinor))); } SafeHtml RenderCardPrice(const Product& p, const Rates& rates) { // With colour variants the headline is the cheapest ("from") price; the // exact figure per colour lives in the selector and the live total. return Format(R"(

    {}{}

    )", p.variants.size() > 1 ? Raw(R"(from )") : SafeHtml{}, PriceSingle(p, rates)); } export RenderedPage RenderShop(std::span products, const Rates& rates) { std::vector cards; for (const Product& p : products) { // width/height matching the committed image keep the card from // reflowing as the photo arrives. SafeHtml thumb = p.image.empty() ? SafeHtml{} : Format( R"()", Url("href", "/shop/" + p.slug), Url("src", p.image)); cards.push_back(Format( R"(
    )" R"({})" R"(
    )" R"(

    {}

    )" R"(

    {}

    )" R"({})" R"(
    )" R"(
    )", thumb, Url("href", "/shop/" + p.slug), Escape(p.name), Escape(p.tagline), p.Buyable() ? RenderCardPrice(p, rates) : p.ComingSoon() ? Format(R"({}

    coming soon

    )", RenderCardPrice(p, rates)) : Raw(R"(

    temporarily unavailable

    )"))); } RenderedPage page; page.meta.title = "Shop — Catcrafts"; page.meta.description = "Fairphone 6 handsets reflashed to run postmarketOS, with the IMS/VoLTE " "stack Catcrafts maintains."; page.meta.canonical = "/shop"; page.meta.geoPriceHint = true; page.main = Format( R"()" R"(
    {}
    )", cards.empty() ? Raw(R"(

    No products listed.

    )") : Join(cards)); return page; } // The money terms, stated wherever prices invite a decision, not only in the // legal pages. Two claims that must be unambiguous: converted prices are // indicative and the charge is euros; and import costs are entirely between // the buyer, the courier and their government — Catcrafts neither collects // nor answers for them. Shared by the live checkout form and the coming-soon // panel so the two can never state different terms. SafeHtml CustomsNote() { return Raw( R"(

    Regional prices shown in non euro currency is )" R"(indicative only. The final amount charged is in euros, your bank may charge conversion fees. )" R"(For delivery outside the EU, the price excludes import duty, )" R"(import VAT or tariffs. Those are )" R"(charged on arrival and are solely a matter between you, the courier and )" R"(your customs authority. Catcrafts does not collect them, cannot )" R"(estimate them bindingly, and is not a party to them.

    )"); } // The checkout form. // // A real
    , not a JavaScript submit handler. It works with // the wasm module absent, blocked or still downloading — which matters more here // than anywhere else on the site, because a silent failure here loses a sale. // // What submitting does — and does not — commit the buyer to is stated right // above the button: an order is created and a payment link shown; nothing is // owed until it is actually paid, and an unpaid order simply lapses. // // `errors` re-renders the form with the previous values preserved. Losing a // filled-in form on a validation error is the fastest way to lose the person. // `liveShipping` is the carrier rate table (country -> cents) when the server // has one; empty otherwise. It feeds the data-cc blob below so the on-page // total preview uses the exact numbers checkout will charge. SafeHtml RenderCheckoutForm(const Product& product, std::span> liveShipping, std::span errors, const Form::Checkout& prev) { auto errorFor = [&](std::string_view field) -> SafeHtml { for (const Form::FieldError& e : errors) { if (e.field == field) { return Format(R"(

    {}

    )", Escape(e.message)); } } return SafeHtml{}; }; // Errors with no field name are form-level (the honeypot, for instance). SafeHtml formError; for (const Form::FieldError& e : errors) { if (e.field.empty()) { formError = Format(R"(

    {}

    )", Escape(e.message)); break; } } // The variant selector. Option labels carry the exact price so the choice // is priced before anything is computed. Default = cheapest = the page's // advertised "from" price. const Variant* selected = product.FindVariant(prev.color); if (!selected) selected = product.CheapestVariant(); std::vector colorOpts; for (const Variant& v : product.variants) { colorOpts.push_back(Format( R"({} — {})", Attr("value", v.slug), (selected && selected->slug == v.slug) ? Raw(" selected") : SafeHtml{}, Escape(v.label), Escape(Money::FormatEuro(v.priceInclMinor)))); } // Everything the total preview may show, pre-computed server-side into one // JSON attribute: per-colour unit prices, zone rates, the live carrier // table. The script multiplies and adds — it invents no number, so the // preview and the charge come from the same integers. std::string cc = R"({"v":{)"; for (std::size_t i = 0; i < product.variants.size(); ++i) { cc += std::format(R"({}"{}":{})", i ? "," : "", product.variants[i].slug, product.variants[i].priceInclMinor); } cc += std::format(R"(}},"from":{},"s":{{"nl":{},"eu":{},"w":{}}},"c":{{)", product.priceInclMinor, product.shipNlMinor, product.shipEuMinor, product.shipWorldMinor); for (std::size_t i = 0; i < liveShipping.size(); ++i) { cc += std::format(R"({}"{}":{})", i ? "," : "", liveShipping[i].first, liveShipping[i].second); } cc += std::format(R"(}},"q":{}}})", Form::kMaxQuantity); return Format( R"(
    )" R"(

    Buy one

    )" R"(

    Submitting creates the order and takes you )" R"(straight to the payment page: iDEAL, card, or a plain bank transfer, )" R"(handled by Mollie. Nothing is owed until you actually pay; an unpaid order )" R"(just lapses. The address is used to ship this order and for the invoice, )" R"(and for nothing else.

    )" // No static rate table: real shipping is priced per country from the // carrier data and shown live in the total below once a country is // entered. A three-zone summary next to exact rates was misinformation. R"(

    Shipping is priced per country at carrier )" R"(rates. Enter your country below and the exact total appears before )" R"(you order.

    )" R"({})" R"({})" R"()" R"()" R"(
    )" R"()" R"()" R"({})" R"(
    )" R"(
    )" // A free number input, not a dropdown: bulk orders are welcome. The // min/max mirror the server's validation bounds; kMaxQuantity is a // technical ceiling, not a sales policy. R"()" R"()" R"({})" R"(
    )" R"(
    )" R"()" R"()" R"(

    Order updates go here. No newsletter exists.

    )" R"({})" R"(
    )" R"(
    )" R"()" R"()" R"({})" R"(
    )" R"(
    )" R"()" R"()" R"({})" R"(
    )" R"(
    )" R"()" R"()" R"({})" R"(
    )" R"(
    )" R"()" R"()" R"({})" R"(
    )" R"(
    )" R"()" R"()" R"(

    Two-letter code. Decides shipping, and whether the )" R"(price includes VAT.

    )" R"({})" R"(
    )" // Honeypot: off-screen rather than display:none, because some bots skip // hidden inputs. aria-hidden + tabindex keeps it away from screen // readers and the keyboard, so no real person can reach it. R"()" // Filled by the script once colour, quantity and country are known; the // exact same total appears on the order page, computed by the server. // Hidden without JS — the customs note already promises the total is // stated on the order page before paying. R"()" R"()" R"()" R"(
    )", CustomsNote(), formError, Url("action", "/shop/" + product.slug + "#buy"), Attr("data-cc", cc), Attr("value", product.slug), Join(colorOpts), errorFor("color"), Attr("max", std::to_string(Form::kMaxQuantity)), Attr("value", std::to_string(prev.quantity)), errorFor("quantity"), Attr("value", prev.email), errorFor("email"), Attr("value", prev.name), errorFor("name"), Attr("value", prev.street), errorFor("street"), Attr("value", prev.postal), errorFor("postal"), Attr("value", prev.city), errorFor("city"), Attr("value", prev.country), errorFor("country")); } export RenderedPage RenderProduct(const Product& product, const Rates& rates, std::span> liveShipping = {}, std::span errors = {}, const Form::Checkout& prev = {}) { std::vector specRows; for (const Spec& s : product.specs) { specRows.push_back(Format(R"({}{})", Escape(s.label), Escape(s.value))); } RenderedPage page; page.meta.title = product.name + " — Catcrafts"; page.meta.description = product.tagline; page.meta.canonical = "/shop/" + product.slug; page.meta.ogType = "product"; page.meta.ogImage = product.image; page.meta.geoPriceHint = true; // The commercial record: one Offer per variant, prices from the same // integers the checkout charges — this markup can never advertise a // number the shop doesn't honour. Availability tracks the status field, // so launch day flips PreOrder to InStock with no edit here. { const std::string productUrl = "https://catcrafts.net/shop/" + product.slug; std::string_view availability = product.Buyable() ? "https://schema.org/InStock" : product.ComingSoon() ? "https://schema.org/PreOrder" : "https://schema.org/OutOfStock"; std::string offers; for (const Variant& v : product.variants) { if (!offers.empty()) offers += ','; offers += std::format( R"({{"@type":"Offer","name":{},"price":{},"priceCurrency":"EUR",)" R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)" R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})", JsonStr(v.label), JsonStr(Money::FormatMinor(v.priceInclMinor)), availability, JsonStr(productUrl)); } // A variantless product still gets its one offer from the base price. if (offers.empty() && product.priceInclMinor > 0) { offers += std::format( R"({{"@type":"Offer","price":{},"priceCurrency":"EUR",)" R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)" R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})", JsonStr(Money::FormatMinor(product.priceInclMinor)), availability, JsonStr(productUrl)); } page.meta.jsonLd = std::format( R"({{"@context":"https://schema.org","@type":"Product",)" R"("name":{},"description":{},"image":{},"url":{},"offers":[{}]}})", JsonStr(product.name), JsonStr(product.tagline), JsonStr(product.image.empty() ? std::string{} : "https://catcrafts.net" + product.image), JsonStr(productUrl), offers); } SafeHtml media = product.image.empty() ? SafeHtml{} : Format( R"({})", Escape(product.name), Url("src", product.image)); // The safety note sits between the summary and the spec sheet — above the // fold, before any number that might close the sale. A safety caveat as // small print at the bottom is the pattern this shop exists to not do. SafeHtml safety = product.safetyNote.empty() ? SafeHtml{} : Format( R"(

    )" R"(Safety warning — emergency calls. {}

    )", Escape(product.safetyNote)); SafeHtml buy; if (product.Buyable()) { buy = RenderCheckoutForm(product, liveShipping, errors, prev); } else if (product.ComingSoon()) { // The launch prices are already public, per colour, with the same // money terms the live form will carry. Only the form is held back, // so opening the shop changes nothing a visitor was already told. std::vector colorRows; for (const Variant& v : product.variants) { colorRows.push_back(Format( R"(
  • {} — {}
  • )", Escape(v.label), Escape(Money::FormatEuro(v.priceInclMinor)))); } buy = Format( R"(
    )" R"(

    Buy one

    )" R"(

    Coming soon. Orders are not open yet; these are )" R"(the launch prices. Watch the posts for the opening.

    )" R"(
      {}
    )" R"({})" R"(
    )", Url("href", "/posts"), Join(colorRows), CustomsNote()); } else { buy = Raw(R"(

    Buy one

    )" R"(

    Temporarily unavailable: sourcing or pricing )" R"(is in flux. Check back, or watch the posts.

    )"); } page.main = Format( R"()" R"({})" R"({})" R"(

    {}

    )" R"({})" R"(
    )" R"(

    Specifications

    )" R"(

    The hardware is a stock Fairphone 6, )" R"(unmodified. Fairphone's spec sheet is this product's spec sheet, )" R"(and all of it works under postmarketOS. The one caveat is the )" R"(emergency-calling warning above.

    )" R"({}
    )" R"(
    )" R"(
    )" R"(

    Warranty

    )" R"(

    {}

    )" R"(
    )" R"({})", Escape(product.name), Escape(product.tagline), media, RenderPriceLine(product, rates), Escape(product.summary), safety, Join(specRows), Escape(product.warranty), buy); return page; } // ── orders ──────────────────────────────────────────────────────────── // One line of an order's money breakdown. SafeHtml MoneyRow(std::string_view label, std::int64_t minor) { return Format(R"({}{})", Escape(label), Escape(Money::FormatEuro(minor))); } // The order status page. The token in the URL is the whole capability: no // account, no login — bookmark the page. Rendered server-side only, because // only the server knows the order; the shared fallback below covers the app. // // `indicative` is the pre-formatted national-currency approximation ("≈ CA$920 // · ECB rate 2026-08-04"), or empty. It arrives as a string on purpose: the // renderer should not know where rates come from. export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indicative) { RenderedPage page; page.meta.title = (o.status == "paid" ? std::string("Order confirmed ") : std::string("Order ")) + o.reference + " — Catcrafts"; // Never indexable and never cached: the URL is a capability and the // content is personal. page.meta.noindex = true; const bool awaiting = o.status == "awaiting_payment"; // No badge when paid: the confirmation notice below carries the state, // and two green "paid" pills stacked read as a rendering bug. SafeHtml statusLine = awaiting ? Raw(R"(awaiting payment)") : o.status == "paid" ? SafeHtml{} : o.status == "shipped" ? Raw(R"(shipped)") : Raw(R"(cancelled)"); // The buyer normally never sees the awaiting state: checkout sends them // straight to Mollie, and coming back the server has already confirmed // the payment on arrival. Reaching it means they abandoned the payment, // so it reads as "resume", not as an alarming limbo. SafeHtml payBlock; if (awaiting && !o.payUrl.empty()) { SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format( R"(

    {}, indicative only. The charge is )" R"(the euro amount above; your bank or card sets the actual conversion )" R"(rate.

    )", Escape(indicative)); payBlock = Format( R"(
    )" R"(

    Complete your payment

    )" R"({})" R"(

    Resume payment — {}

    )" R"(

    The payment page offers iDEAL, cards and a bank )" R"(transfer; your order reference is {}. If you just paid, )" R"(this page confirms it within seconds. A payment left uncompleted simply )" R"(lapses the order. Nothing is owed.

    )" R"(
    )", indicativeLine, Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)), Escape(o.reference)); } else if (o.status == "paid") { payBlock = Format( R"(

    What happens now

    )" R"(

    The device is ordered, flashed and tested, then shipped. Allow up )" R"(to a week before dispatch. Updates land in your email.

    )" R"(

    Download invoice (.md)

    )" R"(

    GPG-clearsigned markdown. It verifies with )" R"(gpg --verify, independent of this site.

    )" R"(
    )", Url("href", "/order/" + o.token + "/invoice.md")); } // The paid state IS the success page — say so before the receipt table. const SafeHtml confirmation = o.status == "paid" ? Raw(R"(

    Payment received. Your order is )" R"(confirmed. This page is your receipt.

    )") : SafeHtml{}; page.main = Format( R"()" R"(

    {}

    )" R"({})" R"(
    )" R"(

    Total

    )" R"()" R"({})" R"({})" R"({})" R"(
    )" R"(

    {}

    )" R"(
    )" R"({})" R"(

    There is no account; this link is the access. )" R"(Download the invoice and keep it. This page is not archived )" R"(forever.

    )", Escape(o.reference), Escape(o.colorLabel.empty() ? o.productName : std::format("{} — {}", o.productName, o.colorLabel)), Escape(o.createdAt), statusLine, confirmation, MoneyRow(o.quantity > 1 ? std::format("Device × {}", o.quantity) : std::string("Device"), o.goodsMinor), MoneyRow("Shipping", o.shippingMinor), MoneyRow("Total", o.totalMinor), o.vatIncluded ? Raw("Includes 21% Dutch VAT.") : Raw("Zero-rated export: no EU VAT charged. Import duty, import " "VAT, tariffs and any carrier handling fee are charged on arrival " "and are solely between you, the courier and your customs " "authority; Catcrafts does not collect them and is not a party " "to them."), payBlock); // A plain is the no-JavaScript way to make the page track // the payment: the browser refetches, the server re-reads the order. Only // while awaiting — a paid page has nothing to poll for. if (awaiting) page.meta.refreshSeconds = 15; return page; } // ── legal ───────────────────────────────────────────────────────────── export RenderedPage RenderLegal(const LegalPage& lp) { std::vector sections; for (const LegalSection& sec : lp.sections) { std::vector paras; for (const std::string& para : sec.body) { paras.push_back(Format(R"(

    {}

    )", Escape(para))); } sections.push_back(Format( R"()", Escape(sec.heading), Join(paras))); } RenderedPage page; page.meta.title = lp.title + " — Catcrafts"; page.meta.description = lp.lede; page.meta.canonical = "/legal/" + lp.slug; page.main = Format( R"()" R"()", Escape(lp.title), Escape(lp.lede), Attr("datetime", lp.updated), Escape(lp.updated), Join(sections)); return page; } // ── about ───────────────────────────────────────────────────────────── // The person behind the company, in the same headed-sections shape (and CSS) // as the legal pages. Carries the ProfilePage/Person JSON-LD that formally // joins "Jorijn van der Graaf" to this domain — the entity link search and // answer engines were previously left to guess at, sometimes wrongly. export RenderedPage RenderAbout(const LegalPage& about) { std::vector sections; for (const LegalSection& sec : about.sections) { std::vector paras; for (const std::string& para : sec.body) { paras.push_back(Format(R"(

    {}

    )", Escape(para))); } sections.push_back(Format( R"()", Escape(sec.heading), Join(paras))); } RenderedPage page; page.meta.title = "About — Catcrafts"; page.meta.description = about.lede; page.meta.canonical = "/about"; page.meta.jsonLd = R"({"@context":"https://schema.org","@type":"ProfilePage","mainEntity":{)" R"("@type":"Person","name":"Jorijn van der Graaf","alternateName":"TheMightyCat",)" R"("url":"https://catcrafts.net/about","nationality":"NL",)" R"("worksFor":{"@type":"Organization","name":"Catcrafts","url":"https://catcrafts.net/"},)" R"("sameAs":["https://invent.kde.org/themightycat",)" R"("https://forgejo.catcrafts.net/Catcrafts/"]}})"; page.main = Format( R"()" R"()", Escape(about.title), Escape(about.lede), Join(sections)); return page; } // ── demos ───────────────────────────────────────────────────────────── export RenderedPage RenderDemos(std::span demos) { std::vector cards; for (const Demo& d : demos) { cards.push_back(Format( R"(
    )" R"(

    {}

    )" R"(

    {}

    )" R"(

    {}

    )" R"(
    )", Url("href", "/demos/" + d.slug), Escape(d.name), Escape(d.blurb), Escape(d.tech))); } RenderedPage page; page.meta.title = "Demos — Catcrafts"; page.meta.description = "Things the Crafter engine can do, running in the browser."; page.meta.canonical = "/demos"; page.main = Format( R"()" R"(
    {}
    )", cards.empty() ? Raw(R"(

    No demos listed.

    )") : Join(cards)); return page; } // A single demo. The mount element is the only thing the renderer needs from // the page: Catcrafts:Demo reparents its canvas into it by id. export RenderedPage RenderDemo(const Demo& d) { // aspect-ratio comes from the content file, so a demo with different // proportions does not need a CSS change. Emitted as a style attribute // because it is per-demo data, not a reusable rule — and it goes through // Attr() so the value cannot break out of the attribute. SafeHtml mount = d.mountId.empty() ? SafeHtml{} : Format( R"(
    )", Attr("id", d.mountId), Attr("style", "aspect-ratio: " + d.aspect)); RenderedPage page; page.meta.title = d.name + " — Catcrafts"; page.meta.description = d.blurb; page.meta.canonical = "/demos/" + d.slug; page.main = Format( R"()" R"()" R"(
    {}{}
    )" R"(
    )" R"(
    Built with
    {}
    )" R"(
    )", Url("href", "/demos"), Escape(d.name), Escape(d.name), Escape(d.blurb), mount, d.needs.empty() ? SafeHtml{} : Format( R"(

    Needs {}

    )", Escape(d.needs)), Escape(d.tech)); return page; } // ── not found ───────────────────────────────────────────────────────── export RenderedPage RenderNotFound(std::string_view path) { RenderedPage page; page.meta.title = "Not found — Catcrafts"; page.meta.noindex = true; page.status = 404; page.main = Format( R"()" R"(

    Go home

    )", Escape(path), Url("href", "/")); return page; } // ── dispatch ────────────────────────────────────────────────────────── export struct SiteContent { std::vector projects; std::vector posts; std::vector products; std::vector legal; std::vector demos; // ECB reference rates, for the indicative local-currency prices. Empty is // fine — every price then shows in euros. Rates rates; const Demo* FindDemo(std::string_view slug) const { for (const Demo& d : demos) { if (d.slug == slug) return &d; } return nullptr; } const LegalPage* FindLegal(std::string_view slug) const { for (const LegalPage& p : legal) { if (p.slug == slug) return &p; } return nullptr; } const Product* FindProduct(std::string_view slug) const { for (const Product& p : products) { if (p.slug == slug) return &p; } return nullptr; } }; RenderedPage RenderRouteBody(const Route& route, const SiteContent& content); // Single entry point both hosts call. Keeping the switch here rather than in // each host is what guarantees a path renders identically on the server and in // the app. export RenderedPage RenderRoute(const Route& route, const SiteContent& content) { // A route carrying a canonical target is a redirect, whatever it renders. // Handled once here rather than per-case: /blog and the retired /demo both // need it, and the next retired URL should not have to remember to set the // status itself. The body is still rendered so a client that ignores the // redirect sees the right content. if (!route.canonicalRedirect.empty()) { RenderedPage page = RenderRouteBody(route, content); page.meta.canonical = route.canonicalRedirect; page.status = 301; return page; } return RenderRouteBody(route, content); } RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) { switch (route.kind) { case RouteKind::Home: return RenderHome(content.projects, content.posts); case RouteKind::About: return RenderAbout(Content::AboutPage()); case RouteKind::Projects: return RenderProjects(content.projects); case RouteKind::Posts: return RenderPosts(content.posts); case RouteKind::Demos: return RenderDemos(content.demos); case RouteKind::Demo: { if (const Demo* d = content.FindDemo(route.slug)) return RenderDemo(*d); break; } case RouteKind::Shop: return RenderShop(content.products, content.rates); case RouteKind::Invoice: case RouteKind::Order: { // Only the server knows order state, and the server intercepts this // route before shared dispatch. Reaching this case means the wasm // app is rendering with the backend down — say so instead of // guessing at a status. RenderedPage page; page.meta.title = "Order status — Catcrafts"; page.meta.noindex = true; page.meta.refreshSeconds = 30; page.main = Raw( R"()"); return page; } case RouteKind::Legal: { if (const LegalPage* lp = content.FindLegal(route.slug)) { return RenderLegal(*lp); } break; } case RouteKind::Product: { // A slug that parsed but names nothing is a 404, not an empty // product page — otherwise every typo becomes an indexable URL. if (const Product* p = content.FindProduct(route.slug)) { return RenderProduct(*p, content.rates); } break; } case RouteKind::LegacyBlog: return RenderPosts(content.posts); case RouteKind::NotFound: break; } return RenderNotFound(route.path); } // ── Atom feed ───────────────────────────────────────────────────────── // Html::Escape's output is valid XML text: & < > " are shared // with XML, and it emits an apostrophe as the numeric reference ' rather // than the HTML-only '. So no separate XML escaper is needed. export std::string RenderAtomFeed(std::span posts) { // is the newest entry's timestamp rather than "now", so an // unchanged post list produces a byte-identical feed and conditional // requests keep working. const std::string_view updated = posts.empty() ? std::string_view("1970-01-01T00:00:00Z") : std::string_view(posts.front().published); std::string out = std::format( "\n" "\n" " Catcrafts\n" " Posts from the fediverse\n" " \n" " \n" " https://catcrafts.net/\n" " {}\n" " Catcrafts\n", Escape(updated).Str()); for (const Post& p : posts) { out += std::format( " \n" " {}\n" // ap_id is a stable, globally unique federated URL — exactly what // an Atom is meant to be. " {}\n" " \n" " {}\n" " {}\n" " \n", Escape(p.title).Str(), Escape(p.permalink).Str(), Escape(p.permalink).Str(), Escape(p.published).Str(), Escape(p.excerpt).Str()); } out += "\n"; return out; } // ── full document (server-side rendering) ───────────────────────────── // The timezone price hint — the only script a shop page carries. // // Why the timezone and not the IP: the question is binary (EU price or export // price), the browser's IANA timezone answers it locally with no permission // prompt, nothing leaves the device, and unlike an IP lookup it is not fooled // by a VPN endpoint. The mapping is "every EU timezone" — anything else, // including Europe/London and Europe/Zurich, correctly falls out as non-EU. // // Both outcomes are tagged (cc-eu / cc-noneu) rather than only one: a // CONFIRMED region hides the inapplicable price line outright, which CSS can // only do if it can tell "confirmed EU" apart from "nothing ran". Failure // mode is the default: no JS, an ancient browser, or an exotic tz leaves the // EU-first dual display, which is complete and honest on its own. The class // goes on so it all happens before first paint — no flash. // // UTC is deliberately "unknown", not "non-EU": Firefox's // privacy.resistFingerprinting reports UTC for everyone, and privacy-hardened // browsers are exactly this shop's clientele. A real person is almost never // genuinely in UTC; a fingerprinting-resistant one frequently claims to be. // They get the dual display, same as no-JS. // Beyond the region classes, the script picks the visitor's display currency // from the same timezone (a currency map of the zones each supported currency // covers) and, once the DOM exists, swaps every .price__single's text for the // matching pre-formatted data attribute. It computes nothing and fetches // nothing — the server rendered every string it is allowed to choose from. inline constexpr std::string_view kGeoPriceHintScript = "\n"; // Wraps a rendered page in a complete HTML document. // // `bootScripts` is the \n"; } return std::format( "\n" "\n\n" "\n" // Only on pages that boot the wasm, and load-bearing there. // // Crafter.Build's runtime.js resolves everything it needs relative to the // DOCUMENT url: fetch("variants.json"), fetch("files.json"), every VFS // entry, and the .wasm named by variants.json. At "/" that is correct; at // /demos/raytracer it asks for /demos/variants.json, gets Caddy's // try_files fallback (index.html), and the whole boot collapses — four // NS_ERROR_CORRUPTED_CONTENT failures and a blank demo. // // A fixes all of them at once because a bare relative fetch() in a // module resolves against the document base URL. Safe here only because // every href/src/action this renderer emits is already absolute, so // nothing else changes meaning — there is an e2e check pinning that. "{}" "\n" // Tells catcrafts-head.js that the server already set the head, so it // does not overwrite an SSR'd title with the generic one. "\n" "{}\n" "{}{}{}{}{}" "\n" "\n" "\n" "{}" "\n\n" "
    \n" "
    {}
    \n" "
    {}
    \n" "
    {}
    \n" "
    \n" "\n\n", bootScripts.empty() ? "" : "\n", Html::Escape(page.meta.title).Str(), page.meta.description.empty() ? std::string{} : std::format("\n", Html::Attr("content", page.meta.description).Str()), page.meta.noindex ? "\n" : "", page.meta.refreshSeconds > 0 ? std::format("\n", page.meta.refreshSeconds) : std::string{}, page.meta.geoPriceHint ? kGeoPriceHintScript : std::string_view{}, (canonical.empty() ? std::string{} : canonical + "\n") + headExtras, cssHref, bootScripts, nav.Str(), page.main.Str(), footer.Str()); } } // namespace Catcrafts::Views