catcrafts.net/shared/interfaces/Catcrafts.Shared-Views.cppm
Jorijn van der Graaf fca089c20d
Some checks failed
Deploy / build-deploy (push) Failing after 7m0s
SEO
2026-08-06 23:42:25 +02:00

1628 lines
81 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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 <main>, 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 <a href> links, not click-only elements.
//
// The previous version used `<a id="blog-nav-button">` 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<SafeHtml> items;
for (const NavItem& item : NavItems()) {
const bool active = item.kind == current;
items.push_back(Format(
R"(<li><a class="nav-link{}"{}{}>{}</a></li>)",
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 <img> 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 <a> rather than text: on narrow viewports .logo__text
// is display:none, which would leave the link with no accessible name.
return Format(
R"(<div class="nav-container">)"
R"(<a class="logo" aria-label="Catcrafts"{}>)"
R"(<span class="logo__mark" aria-hidden="true">)"
R"(<svg viewBox="0 0 64 64" fill="none">)"
R"(<path d="M9 26 L13 4 L23 15 Q32 11 41 15 L51 4 L55 26 L54 38 Q52 56 32 56 Q12 56 10 38 Z" stroke="currentColor" stroke-width="5" stroke-linejoin="round"/>)"
R"(<path d="M10.5 40 L4.5 42 M11.5 46 L6 49 M53.5 40 L59.5 42 M52.5 46 L58 49" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"/>)"
R"(<path d="M17 32 L23 25 L29 32 M35 32 L41 25 L47 32" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)"
// R"x(...)x": stroke="var(--text)" contains the plain-delimiter
// terminator )" and would end the literal mid-attribute.
R"x(<path d="M23 38 L29 43 L23 48" stroke="var(--text)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)x"
R"x(<path class="logo-cursor" d="M34 48 L42 48" stroke="var(--text)" stroke-width="5" stroke-linecap="round"/>)x"
R"(</svg>)"
R"(</span>)"
R"(<span class="logo__text">Catcrafts</span></a>)"
R"(<nav aria-label="Main"><ul class="nav-list">{}</ul></nav>)"
R"(</div>)",
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"(<div class="footer-content">)"
R"(<p class="footer-tagline">Open-source systems work from the Netherlands. )"
R"(This site is C++ compiled to WebAssembly.</p>)"
R"(<p class="footer-links">)"
R"(<a{}>Forgejo</a><a{}>Source</a>)"
R"(</p>)"
R"(<p class="footer-links">)"
R"(<a{}>Privacy</a><a{}>Terms</a><a{}>Imprint &amp; contact</a>)"
R"(</p>)"
R"(<p class="footer-legal">&copy; 2026 Catcrafts&reg;. Crafter&reg; and Catcrafts&reg; )"
R"(are registered trademarks with the EUIPO.</p>)"
R"(</div>)",
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;
// '<' so "</script>" can never appear inside the ld+json block:
// the HTML parser ends a <script> at that byte sequence wherever
// it sits, string literal or not. < is the same character to
// every JSON consumer and invisible to the HTML parser.
case '<': out += "\\u003c"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
} else {
out += c;
}
}
}
out += '"';
return out;
}
// ── home ──────────────────────────────────────────────────────────────
export RenderedPage RenderHome(std::span<const Project> projects,
std::span<const Post> posts) {
std::vector<SafeHtml> cards;
for (const Project& p : projects) {
if (!p.featured) continue;
cards.push_back(Format(
R"(<article class="mini-card">)"
R"(<h3 class="mini-card__title"><a{}>{}</a></h3>)"
R"(<p class="mini-card__blurb">{}</p>)"
R"(</article>)",
Url("href", p.url), Escape(p.name), Escape(p.blurb)));
}
std::vector<SafeHtml> recent;
for (std::size_t i = 0; i < posts.size() && i < 3; ++i) {
const Post& post = posts[i];
recent.push_back(Format(
R"(<li class="recent__item"><a{}>{}</a><span class="recent__date">{}</span></li>)",
Url("href", post.permalink),
Escape(post.title),
Escape(DateOnly(post.published))));
}
SafeHtml main = Format(
R"(<section class="hero">)"
// No slogan on purpose: a hero headline reads as marketing, which is
// not the register this site wants. Instead the mission sentence IS
// the h1 — full weight in the document outline and for crawlers —
// styled as body prose (see h1.hero__lede) so nothing on screen
// changes register. The old version hid a one-word h1 offscreen,
// which spent the strongest heading on the page saying "Catcrafts"
// to nobody.
R"(<h1 class="hero__lede">Catcrafts makes accessible, open-source software )"
R"(and hardware for the benefit of everyone.</h1>)"
R"(<p class="hero__lede">The goal: a real alternative )"
R"(to big tech that anyone can use, not just Linux nerds.</p>)"
R"(<p class="hero__actions">)"
R"(<a class="btn btn--primary"{}>Browse projects</a>)"
R"(<a class="btn"{}>Browse shop</a>)"
R"(</p>)"
R"(</section>)"
R"(<section class="section">)"
R"(<h2 class="section__title">Featured work</h2>)"
R"(<div class="mini-grid">{}</div>)"
R"(</section>)"
R"({})",
Url("href", "/projects"),
Url("href", "/shop"),
cards.empty() ? Raw(R"(<p class="empty">No featured projects yet.</p>)") : Join(cards),
recent.empty() ? SafeHtml{} : Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Recent posts</h2>)"
R"(<ul class="recent">{}</ul>)"
R"(<p><a class="link-more"{}>All posts</a></p>)"
R"(</section>)",
Join(recent), Url("href", "/posts")));
RenderedPage page;
page.meta.title = "Catcrafts — open-source software and hardware";
// Deliberately the hero text, word for word: the mission sentence is the
// site's best self-description, and the search snippet, the og: link
// card and the page itself should all say the same thing. If the hero
// below changes, change this with it.
page.meta.description =
"Catcrafts makes accessible, open-source software and hardware for "
"the benefit of everyone. The goal: a real alternative to big tech "
"that anyone can use, not just Linux nerds.";
page.meta.canonical = "/";
// The identity record: name, registered 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.
//
// Two nodes in a @graph rather than one bare Organization, joined by @id:
//
// #organization — who the company is. Referenced by @id from the other
// pages (about's worksFor, the shop's seller) so every page describes
// ONE entity instead of three same-named copies that a consumer has
// to guess are the same. That guess is exactly what goes wrong with a
// name this generic.
// #website — what this domain is. The navigational query "catcrafts" is
// answered from the site entity, not the company entity, and it is
// where name/alternateName are read for it. Without this node there
// is nothing here typed as the thing being searched for.
//
// alternateName carries the spellings people actually type. The one
// spelling deliberately absent is "Cat Crafts" with a space: that is the
// generic craft phrase owned by Etsy, Pinterest and a decade of kids'
// craft blogs, and claiming it would argue for merging this entity into
// the corpus it needs to stay distinct from.
//
// identifier restates KVK/VAT/EORI as typed PropertyValues. vatID stays
// as well — it is the property Google documents — but the registry
// numbers are the part no name-twin can produce, and a Minecraft server
// and a cat charity cannot: they are checkable against a public register.
page.meta.jsonLd =
R"({"@context":"https://schema.org","@graph":[)"
R"({"@type":"Organization","@id":"https://catcrafts.net/#organization",)"
R"("name":"Catcrafts","alternateName":["Catcrafts.net","CatCrafts"],)"
R"("url":"https://catcrafts.net/",)"
R"("logo":"https://catcrafts.net/favicon.svg",)"
R"("email":"info@catcrafts.net","vatID":"NL003329281B38",)"
R"("identifier":[)"
R"({"@type":"PropertyValue","propertyID":"KVK","value":"78437059"},)"
R"({"@type":"PropertyValue","propertyID":"VAT","value":"NL003329281B38"},)"
R"({"@type":"PropertyValue","propertyID":"EORI","value":"NL1900095326"}],)"
// The founder is a reference, exactly like the org: the Person node's
// full definition lives on /about under this @id, so both pages talk
// about ONE person — the same join, for the same reason.
R"("founder":{"@type":"Person","@id":"https://catcrafts.net/about#person",)"
R"("name":"Jorijn van der Graaf","url":"https://catcrafts.net/about"},)"
R"("description":"Catcrafts makes accessible, open-source software and hardware: )"
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and Fairphone 6 )"
R"(handsets sold with postmarketOS preinstalled.",)"
R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]},)"
R"({"@type":"WebSite","@id":"https://catcrafts.net/#website",)"
R"("name":"Catcrafts","alternateName":["Catcrafts.net","CatCrafts"],)"
R"("url":"https://catcrafts.net/","inLanguage":"en",)"
R"("publisher":{"@id":"https://catcrafts.net/#organization"}}]})";
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<const Project> projects) {
std::vector<SafeHtml> cards;
for (const Project& p : projects) {
cards.push_back(Format(
R"(<article class="project-card">)"
R"(<h2 class="project-card__title"><a{}>{}</a></h2>)"
R"(<p class="project-card__blurb">{}</p>)"
R"(<span class="project-card__lang">{}</span>)"
R"(</article>)",
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++ build system, graphics engine, networking "
"stack, and an IMS/VoLTE implementation for the Fairphone 6.";
page.meta.canonical = "/projects";
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">Projects</h1>)"
R"(<p class="page-header__lede">The short version of each. )"
R"(Docs, status and source live on <a{}>Forgejo</a>; )"
R"(every card links to its repository.</p>)"
R"(</header>)"
R"(<div class="project-grid">{}</div>)",
Url("href", "https://forgejo.catcrafts.net/Catcrafts/"),
cards.empty() ? Raw(R"(<p class="empty">Project list unavailable.</p>)") : 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<const PostMedia> media) {
if (media.empty()) return SafeHtml{};
std::vector<SafeHtml> 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"(<video class="post-media__item" controls preload="metadata" )"
R"(playsinline{}{}{}></video>)",
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"(<img class="post-media__item" loading="lazy" decoding="async" )"
R"(alt=""{}{}>)",
Url("src", m.src), dims));
}
}
return Format(R"(<div class="post-media">{}</div>)", Join(items));
}
export RenderedPage RenderPosts(std::span<const Post> posts) {
std::vector<SafeHtml> 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"(<p class="post-card__link"><a{} rel="nofollow noopener">{}</a></p>)",
Url("href", p.linkUrl), Escape(p.linkUrl));
cards.push_back(Format(
R"(<article class="post-card">)"
R"(<header class="post-card__header">)"
R"(<h2 class="post-card__title"><a{} rel="noopener">{}</a></h2>)"
R"(<p class="post-card__meta">)"
R"(<time{}>{}</time><span class="post-card__community">{}</span>)"
R"(</p>)"
R"(</header>)"
R"({})"
R"({})"
R"({})"
R"(<footer class="post-card__footer">)"
R"(<span class="stat">{} points</span>)"
R"(<a class="stat stat--link"{} rel="noopener">Discuss on the fediverse ({} comments) &rarr;</a>)"
R"(</footer>)"
R"(</article>)",
Url("href", p.permalink), Escape(p.title),
Attr("datetime", p.published), Escape(DateOnly(p.published)),
Escape(p.community),
p.excerpt.empty() ? SafeHtml{}
: Format(R"(<p class="post-card__excerpt">{}</p>)", 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"(<header class="page-header">)"
R"(<h1 class="page-header__title">Posts</h1>)"
R"(<p class="page-header__lede">Catcrafts posts on the fediverse rather than keeping a blog here. )"
R"(Each of these links to the original thread on whichever instance it lives on. )"
R"(Follow one to read it and join the discussion there.</p>)"
R"(</header>)"
R"(<div class="post-list">{}</div>)",
cards.empty()
? Raw(R"(<p class="empty">No posts loaded right now.</p>)")
: 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"(<p class="price price--product">)"
R"({}{})"
R"(</p>)",
p.variants.size() > 1
? Raw(R"(<span class="price__from">from</span> )") : 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<SafeHtml> 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<char>(c - 'A' + 'a');
attrs.push_back(Attr(name, std::format(
"~{}{}", row.cur.symbol, Money::ConvertIndicative(base, rate))));
}
return Format(
R"(<span class="price__single"{}>{}</span>)",
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 class="price price--card">{}{}</p>)",
p.variants.size() > 1
? Raw(R"(<span class="price__from">from</span> )") : SafeHtml{},
PriceSingle(p, rates));
}
export RenderedPage RenderShop(std::span<const Product> products, const Rates& rates) {
std::vector<SafeHtml> 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"(<a class="product-card__media"{} tabindex="-1" aria-hidden="true">)"
R"(<img class="product-card__img" loading="lazy" decoding="async" alt=""{}></a>)",
Url("href", "/shop/" + p.slug), Url("src", p.image));
cards.push_back(Format(
R"(<article class="product-card">)"
R"({})"
R"(<div class="product-card__body">)"
R"(<h2 class="product-card__title"><a{}>{}</a></h2>)"
R"(<p class="product-card__tagline">{}</p>)"
R"({})"
R"(</div>)"
R"(</article>)",
thumb,
Url("href", "/shop/" + p.slug), Escape(p.name), Escape(p.tagline),
p.Buyable() ? RenderCardPrice(p, rates)
: p.ComingSoon()
? Format(R"({}<p class="product-card__status"><span class="badge badge--experiment">coming soon</span></p>)",
RenderCardPrice(p, rates))
: Raw(R"(<p class="product-card__status"><span class="badge badge--experiment">temporarily unavailable</span></p>)")));
}
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;
// The category page as typed data: an ItemList naming each product page.
// This is the shape crawlers read a listing page in, and it makes every
// product URL discoverable from markup instead of from anchor-parsing.
// Deliberately just position/name/url — the prices and offers live in
// each product page's own record, which is their single source of truth.
{
std::string items;
for (std::size_t i = 0; i < products.size(); ++i) {
if (!items.empty()) items += ',';
items += std::format(
R"({{"@type":"ListItem","position":{},"name":{},"url":{}}})",
i + 1, JsonStr(products[i].name),
JsonStr("https://catcrafts.net/shop/" + products[i].slug));
}
if (!items.empty()) {
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"ItemList",)"
R"("itemListElement":[{}]}})",
items);
}
}
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">Shop</h1>)"
R"(<p class="page-header__lede">Hardware that runs the software from the )"
R"(projects page. Assembled to order and flashed. Please allow up to a )"
R"(week before dispatch. Payment is handled by Mollie.)"
R"(</header>)"
R"(<div class="product-grid">{}</div>)",
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : 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"(<p class="checkout__customs">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.</p>)");
}
// The checkout form.
//
// A real <form method="post">, 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<const std::pair<std::string, std::int64_t>> liveShipping,
std::span<const Form::FieldError> 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"(<p class="field__error">{}</p>)", 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"(<p class="notice notice--error">{}</p>)", 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<SafeHtml> colorOpts;
for (const Variant& v : product.variants) {
colorOpts.push_back(Format(
R"(<option{}{}>{} &mdash; {}</option>)",
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"(<section class="checkout" id="buy">)"
R"(<h2 class="section__title">Buy one</h2>)"
R"(<p class="checkout__lede">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.</p>)"
// 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"(<p class="checkout__shipnote">Shipping is priced per country at carrier )"
R"(rates. Enter your country below and the exact total appears before )"
R"(you order.</p>)"
R"({})"
R"({})"
R"(<form class="form" method="post"{}{} novalidate>)"
R"(<input type="hidden" name="product"{}>)"
R"(<div class="field">)"
R"(<label for="f-color">Colour</label>)"
R"(<select id="f-color" name="color">{}</select>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
// 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"(<label for="f-qty">Quantity</label>)"
R"(<input id="f-qty" name="quantity" type="number" inputmode="numeric" )"
R"(min="1"{}{}>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-email">Email <span class="field__req">required</span></label>)"
R"(<input id="f-email" name="email" type="email" autocomplete="email" required{}>)"
R"(<p class="field__hint">Order updates go here. No newsletter exists.</p>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-name">Recipient name <span class="field__req">required</span></label>)"
R"(<input id="f-name" name="name" type="text" autocomplete="name" maxlength="120" required{}>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-street">Street and number <span class="field__req">required</span></label>)"
R"(<input id="f-street" name="street" type="text" autocomplete="street-address" maxlength="200" required{}>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-postal">Postal code <span class="field__req">required</span></label>)"
R"(<input id="f-postal" name="postal" type="text" autocomplete="postal-code" maxlength="20" required{}>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-city">City <span class="field__req">required</span></label>)"
R"(<input id="f-city" name="city" type="text" autocomplete="address-level2" maxlength="120" required{}>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-country">Country <span class="field__req">required</span></label>)"
R"(<input id="f-country" name="country" type="text" autocomplete="country" )"
R"(maxlength="2" placeholder="NL" required{}>)"
R"(<p class="field__hint">Two-letter code. Decides shipping, and whether the )"
R"(price includes VAT.</p>)"
R"({})"
R"(</div>)"
// 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"(<div class="honeypot" aria-hidden="true">)"
R"(<label for="f-website">Leave this empty</label>)"
R"(<input id="f-website" name="website" type="text" tabindex="-1" autocomplete="off">)"
R"(</div>)"
// 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"(<p class="checkout__total" id="cc-total" hidden></p>)"
R"(<button class="btn btn--primary" type="submit">Order &mdash; continue to payment</button>)"
R"(</form>)"
R"(</section>)",
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<const std::pair<std::string, std::int64_t>> liveShipping = {},
std::span<const Form::FieldError> errors = {},
const Form::Checkout& prev = {}) {
std::vector<SafeHtml> specRows;
for (const Spec& s : product.specs) {
specRows.push_back(Format(R"(<tr><th scope="row">{}</th><td>{}</td></tr>)",
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: a ProductGroup with one variant Product per
// colour, each carrying its ONE offer, prices from the same integers the
// checkout charges — this markup can never advertise a number the shop
// doesn't honour. A group rather than one Product with three priced
// offers, because multiple offers on a product read as multiple sellers
// of the same thing, and which price gets quoted is then the consumer's
// guess; as variants, each colour owns its price. Availability tracks
// the status field, so launch day flips PreOrder to InStock with no edit
// here.
//
// Merchant-grade: each offer also carries shippingDetails and a return
// policy, which is what Google Merchant Center's website-crawl feed needs
// to list the product without a CSV in sight — productGroupID is what it
// maps to item_group_id. Shipping uses the STATIC zone rates on purpose:
// the checkout charges live carrier rates, which run at or below the
// zone fallbacks — a listing may overstate shipping, never understate it.
{
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";
// "NL", then the other 26 EU members, as JSON string lists.
std::string euList;
for (std::string_view cc : Money::EuCountries()) {
if (cc == "NL") continue;
if (!euList.empty()) euList += ',';
euList += JsonStr(cc);
}
// The world tier can't say "everywhere else" in schema.org, so it
// names the non-EU destinations the shop actually sees demand from.
static constexpr std::string_view kWorldSample[] = {
"US", "CA", "GB", "CH", "NO", "AU", "NZ", "JP",
};
std::string worldList;
for (std::string_view cc : kWorldSample) {
if (!worldList.empty()) worldList += ',';
worldList += JsonStr(cc);
}
auto shipTier = [](std::string_view rate, const std::string& dests,
int transitMin, int transitMax) {
return std::format(
R"({{"@type":"OfferShippingDetails",)"
R"("shippingRate":{{"@type":"MonetaryAmount","value":{},"currency":"EUR"}},)"
R"("shippingDestination":{{"@type":"DefinedRegion","addressCountry":[{}]}},)"
R"("deliveryTime":{{"@type":"ShippingDeliveryTime",)"
R"("handlingTime":{{"@type":"QuantitativeValue","minValue":1,"maxValue":7,"unitCode":"DAY"}},)"
R"("transitTime":{{"@type":"QuantitativeValue","minValue":{},"maxValue":{},"unitCode":"DAY"}}}}}})",
JsonStr(rate), dests, transitMin, transitMax);
};
const std::string shippingDetails = "["
+ shipTier(Money::FormatMinor(product.shipNlMinor), JsonStr("NL"), 1, 2) + ","
+ shipTier(Money::FormatMinor(product.shipEuMinor), euList, 2, 5) + ","
+ shipTier(Money::FormatMinor(product.shipWorldMinor), worldList, 5, 14) + "]";
// Returns, matching the terms page: EU consumers get the statutory
// 14-day withdrawal (return shipping theirs); outside the EU sales
// are final except defects, which are warranty, not returns.
std::string euAll;
for (std::string_view cc : Money::EuCountries()) {
if (!euAll.empty()) euAll += ',';
euAll += JsonStr(cc);
}
const std::string returnPolicy = std::format(
R"([{{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
R"("returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow",)"
R"("merchantReturnDays":14,"returnMethod":"https://schema.org/ReturnByMail",)"
R"("returnFees":"https://schema.org/ReturnFeesCustomerResponsibility"}},)"
R"({{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
R"("returnPolicyCategory":"https://schema.org/MerchantReturnNotPermitted"}}])",
euAll, worldList);
const std::string offerTail = std::format(
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
// Same @id as the home page's Organization: the seller of these
// offers is the KVK-registered company, and joining the nodes is
// what carries that registration onto the offer instead of
// leaving a bare name a consumer has to resolve by string match.
R"("url":{},"seller":{{"@id":"https://catcrafts.net/#organization",)"
R"("@type":"Organization","name":"Catcrafts"}},)"
R"("shippingDetails":{},"hasMerchantReturnPolicy":{}}})",
availability, JsonStr(productUrl), shippingDetails, returnPolicy);
const std::string brand = product.brand.empty()
? std::string{}
: std::format(R"("brand":{{"@type":"Brand","name":{}}},)",
JsonStr(product.brand));
// Omitted entirely when there is no photo: "image":"" is not an
// absent image, it is a broken claim about one.
const std::string image = product.image.empty()
? std::string{}
: std::format(R"("image":{},)",
JsonStr("https://catcrafts.net" + product.image));
if (product.variants.size() > 1) {
// Shared facts (brand, description, image) sit on the group and
// are inherited; each variant states only what varies — colour,
// sku, price — plus the offer terms every colour shares.
std::string variantNodes;
for (const Variant& v : product.variants) {
if (!variantNodes.empty()) variantNodes += ',';
variantNodes += std::format(
R"({{"@type":"Product","name":{},"sku":{},"color":{},)"
R"("offers":{{"@type":"Offer","price":{},"priceCurrency":"EUR",)",
JsonStr(product.name + "" + v.label),
JsonStr(product.slug + "-" + v.slug),
JsonStr(v.label),
JsonStr(Money::FormatMinor(v.priceInclMinor)));
variantNodes += offerTail; // closes the Offer
variantNodes += '}'; // closes the variant Product
}
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"ProductGroup",)"
R"("name":{},{}"description":{},{}"url":{},)"
R"("productGroupID":{},"variesBy":["https://schema.org/color"],)"
R"("hasVariant":[{}]}})",
JsonStr(product.name), brand, JsonStr(product.tagline), image,
JsonStr(productUrl), JsonStr(product.slug), variantNodes);
} else {
// No colour choice, no group: a plain Product with its one offer.
const std::string sku = product.variants.empty()
? product.slug
: product.slug + "-" + product.variants[0].slug;
std::string offer;
if (product.priceInclMinor > 0) {
offer = std::format(
R"(,"offers":{{"@type":"Offer","price":{},"priceCurrency":"EUR",)",
JsonStr(Money::FormatMinor(product.priceInclMinor)));
offer += offerTail;
}
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"Product",)"
R"("name":{},{}"description":{},{}"url":{},"sku":{}{}}})",
JsonStr(product.name), brand, JsonStr(product.tagline), image,
JsonStr(productUrl), JsonStr(sku), offer);
}
}
SafeHtml media = product.image.empty() ? SafeHtml{} : Format(
R"(<img class="product__photo" decoding="async" alt="{}"{}>)",
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"(<p class="notice notice--warn product__safety">)"
R"(<strong>Safety warning &mdash; emergency calls.</strong> {}</p>)",
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<SafeHtml> colorRows;
for (const Variant& v : product.variants) {
colorRows.push_back(Format(
R"(<li>{} &mdash; {}</li>)",
Escape(v.label), Escape(Money::FormatEuro(v.priceInclMinor))));
}
buy = Format(
R"(<section class="checkout" id="buy">)"
R"(<h2 class="section__title">Buy one</h2>)"
R"(<p class="notice">Coming soon. Orders are not open yet; these are )"
R"(the launch prices. Watch the <a{}>posts</a> for the opening.</p>)"
R"(<ul class="checkout__colors">{}</ul>)"
R"({})"
R"(</section>)",
Url("href", "/posts"), Join(colorRows), CustomsNote());
} else {
buy = Raw(R"(<section class="checkout"><h2 class="section__title">Buy one</h2>)"
R"(<p class="notice">Temporarily unavailable: sourcing or pricing )"
R"(is in flux. Check back, or watch the posts.</p></section>)");
}
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="page-header__lede">{}</p>)"
R"(</header>)"
R"({})"
R"({})"
R"(<p class="product__summary">{}</p>)"
R"({})"
R"(<section class="section">)"
R"(<h2 class="section__title">Specifications</h2>)"
R"(<p class="section__lede">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.</p>)"
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
R"(</section>)"
R"(<section class="section">)"
R"(<h2 class="section__title">Warranty</h2>)"
R"(<p>{}</p>)"
R"(</section>)"
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"(<tr><th scope="row">{}</th><td class="order__amount">{}</td></tr>)",
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"(<span class="badge badge--experiment">awaiting payment</span>)")
: o.status == "paid" ? SafeHtml{}
: o.status == "shipped" ? Raw(R"(<span class="badge badge--active">shipped</span>)")
: Raw(R"(<span class="badge">cancelled</span>)");
// 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"(<p class="order__indicative">{}, indicative only. The charge is )"
R"(the euro amount above; your bank or card sets the actual conversion )"
R"(rate.</p>)",
Escape(indicative));
payBlock = Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Complete your payment</h2>)"
R"({})"
R"(<p><a class="btn btn--primary" rel="noreferrer"{}>Resume payment &mdash; {}</a></p>)"
R"(<p class="order__note">The payment page offers iDEAL, cards and a bank )"
R"(transfer; your order reference is <strong>{}</strong>. If you just paid, )"
R"(this page confirms it within seconds. A payment left uncompleted simply )"
R"(lapses the order. Nothing is owed.</p>)"
R"(</section>)",
indicativeLine,
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
Escape(o.reference));
} else if (o.status == "paid") {
payBlock = Format(
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )"
R"(to a week before dispatch. Updates land in your email.</p>)"
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
R"(gpg --verify, independent of this site.</p>)"
R"(</section>)",
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"(<p class="notice notice--ok">Payment received. Your order is )"
R"(confirmed. This page is your receipt.</p>)")
: SafeHtml{};
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">Order {}</h1>)"
R"(<p class="page-header__lede">{} &middot; placed {}</p>)"
R"(</header>)"
R"(<p>{}</p>)"
R"({})"
R"(<section class="section">)"
R"(<h2 class="section__title">Total</h2>)"
R"(<table class="spec-table"><tbody>)"
R"({})"
R"({})"
R"({})"
R"(</tbody></table>)"
R"(<p class="order__vat">{}</p>)"
R"(</section>)"
R"({})"
R"(<p class="order__keep">There is no account; this link is the access. )"
R"(Download the invoice and keep it. This page is not archived )"
R"(forever.</p>)",
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 <meta refresh> 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<SafeHtml> sections;
for (const LegalSection& sec : lp.sections) {
std::vector<SafeHtml> paras;
for (const std::string& para : sec.body) {
paras.push_back(Format(R"(<p>{}</p>)", Escape(para)));
}
sections.push_back(Format(
R"(<section class="legal__section">)"
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
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"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="page-header__lede">{}</p>)"
R"(<p class="legal__updated">Last updated <time{}>{}</time></p>)"
R"(</header>)"
R"(<div class="legal">{}</div>)",
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<SafeHtml> sections;
for (const LegalSection& sec : about.sections) {
std::vector<SafeHtml> paras;
for (const std::string& para : sec.body) {
paras.push_back(Format(R"(<p>{}</p>)", Escape(para)));
}
sections.push_back(Format(
R"(<section class="legal__section">)"
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
Escape(sec.heading), Join(paras)));
}
RenderedPage page;
page.meta.title = "About — Catcrafts";
page.meta.description = about.lede;
page.meta.canonical = "/about";
// dateModified is real metadata, not decoration: profile-page consumers
// read it to judge freshness, and the content already tracks the date.
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"ProfilePage","dateModified":{},)"
R"("mainEntity":{{)"
// This @id is the Person node's canonical home; the home page's
// founder property references it rather than redefining the person —
// the identical join the Organization gets, for the identical reason.
R"("@type":"Person","@id":"https://catcrafts.net/about#person",)"
R"("name":"Jorijn van der Graaf","alternateName":"TheMightyCat",)"
R"("url":"https://catcrafts.net/about",)"
// A typed Country, not the bare string "NL": nationality expects a
// Country, and a two-letter string reads as a name, not a code.
R"("nationality":{{"@type":"Country","name":"Netherlands"}},)"
// @id, not just name+url: this is the same node the home page defines
// in full, and saying so is what makes "Jorijn van der Graaf works for
// Catcrafts" and "Catcrafts is KVK 78437059" one fact about one
// company rather than two unlinked claims. Name and url stay so the
// page still stands alone for a consumer that never fetches the home.
R"("worksFor":{{"@id":"https://catcrafts.net/#organization",)"
R"("@type":"Organization","name":"Catcrafts","url":"https://catcrafts.net/"}},)"
// Only personal profiles. The Forgejo /Catcrafts/ org namespace is
// the COMPANY's profile — the home page's Organization claims it —
// and sameAs on two different entities asserts they are the same
// thing, which is the exact confusion this graph exists to prevent.
// The person's own Forgejo account is listed instead; the company
// one is reached through worksFor, not by claiming it. The ani.social
// account is the one the posts page's threads are fetched from —
// this line is the ONE place the site names it (posts-sources.json
// explains why the posts page itself never does).
R"("sameAs":["https://invent.kde.org/themightycat",)"
R"("https://forgejo.catcrafts.net/jorijnvdgraaf",)"
R"("https://ani.social/u/TheMightyCat"]}}}})",
JsonStr(about.updated));
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="page-header__lede">{}</p>)"
R"(</header>)"
R"(<div class="legal">{}</div>)",
Escape(about.title), Escape(about.lede),
Join(sections));
return page;
}
// ── demos ─────────────────────────────────────────────────────────────
export RenderedPage RenderDemos(std::span<const Demo> demos) {
std::vector<SafeHtml> cards;
for (const Demo& d : demos) {
cards.push_back(Format(
R"(<article class="demo-card">)"
R"(<h2 class="demo-card__title"><a{}>{}</a></h2>)"
R"(<p class="demo-card__blurb">{}</p>)"
R"(<p class="demo-card__meta"><span class="demo-card__tech">{}</span></p>)"
R"(</article>)",
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"(<header class="page-header">)"
R"(<h1 class="page-header__title">Demos</h1>)"
R"(<p class="page-header__lede">Things the engine does, running in your browser )"
R"(rather than in a video of someone else's machine.</p>)"
R"(</header>)"
R"(<div class="demo-grid">{}</div>)",
cards.empty() ? Raw(R"(<p class="empty">No demos listed.</p>)") : 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"(<div class="demo-stage"{}{}></div>)",
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"(<p class="breadcrumb"><a{}>Demos</a> / {}</p>)"
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="page-header__lede">{}</p>)"
R"(</header>)"
R"(<div class="demo">{}{}</div>)"
R"(<dl class="demo-facts">)"
R"(<dt>Built with</dt><dd>{}</dd>)"
R"(</dl>)",
Url("href", "/demos"), Escape(d.name),
Escape(d.name), Escape(d.blurb),
mount,
d.needs.empty() ? SafeHtml{} : Format(
R"(<p class="demo__requires">Needs {}</p>)", 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"(<header class="page-header">)"
R"(<h1 class="page-header__title">Not found</h1>)"
R"(<p class="page-header__lede">There's nothing at <code>{}</code>.</p>)"
R"(</header>)"
R"(<p><a class="btn btn--primary"{}>Go home</a></p>)",
Escape(path), Url("href", "/"));
return page;
}
// ── dispatch ──────────────────────────────────────────────────────────
export struct SiteContent {
std::vector<Project> projects;
std::vector<Post> posts;
std::vector<Product> products;
std::vector<LegalPage> legal;
std::vector<Demo> 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"(<header class="page-header">)"
R"(<h1 class="page-header__title">Order status unavailable</h1>)"
R"(<p class="page-header__lede">The status service isn't reachable right )"
R"(now. Nothing is wrong with your order. This page just can't read )"
R"(it at the moment. It retries automatically; keep the link.</p>)"
R"(</header>)");
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: &amp; &lt; &gt; &quot; are shared
// with XML, and it emits an apostrophe as the numeric reference &#39; rather
// than the HTML-only &apos;. So no separate XML escaper is needed.
export std::string RenderAtomFeed(std::span<const Post> posts) {
// <updated> 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(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"
" <title>Catcrafts</title>\n"
" <subtitle>Posts from the fediverse</subtitle>\n"
" <link href=\"https://catcrafts.net/feed.xml\" rel=\"self\"/>\n"
" <link href=\"https://catcrafts.net/posts\"/>\n"
" <id>https://catcrafts.net/</id>\n"
" <updated>{}</updated>\n"
" <author><name>Catcrafts</name></author>\n",
Escape(updated).Str());
for (const Post& p : posts) {
out += std::format(
" <entry>\n"
" <title>{}</title>\n"
// ap_id is a stable, globally unique federated URL — exactly what
// an Atom <id> is meant to be.
" <id>{}</id>\n"
" <link href=\"{}\"/>\n"
" <updated>{}</updated>\n"
" <summary>{}</summary>\n"
" </entry>\n",
Escape(p.title).Str(),
Escape(p.permalink).Str(),
Escape(p.permalink).Str(),
Escape(p.published).Str(),
Escape(p.excerpt).Str());
}
out += "</feed>\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 <html> 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 =
"<script>"
"(function(){try{"
"var z=Intl.DateTimeFormat().resolvedOptions().timeZone;"
"if(!z||z===\"UTC\"||z.indexOf(\"Etc/\")===0)return;"
"var eu=[\"Europe/Amsterdam\",\"Europe/Athens\",\"Europe/Berlin\",\"Europe/Bratislava\","
"\"Europe/Brussels\",\"Europe/Bucharest\",\"Europe/Budapest\",\"Europe/Busingen\","
"\"Europe/Copenhagen\",\"Europe/Dublin\",\"Europe/Helsinki\",\"Europe/Lisbon\","
"\"Europe/Ljubljana\",\"Europe/Luxembourg\",\"Europe/Madrid\",\"Europe/Malta\","
"\"Europe/Mariehamn\",\"Europe/Paris\",\"Europe/Prague\",\"Europe/Riga\","
"\"Europe/Rome\",\"Europe/Sofia\",\"Europe/Stockholm\",\"Europe/Tallinn\","
"\"Europe/Vatican\",\"Europe/Vienna\",\"Europe/Vilnius\",\"Europe/Warsaw\","
"\"Europe/Zagreb\",\"Asia/Nicosia\",\"Asia/Famagusta\",\"Atlantic/Azores\","
"\"Atlantic/Canary\",\"Atlantic/Madeira\",\"Africa/Ceuta\"];"
"var isEu=eu.indexOf(z)>-1;"
"document.documentElement.className+=isEu?\" cc-eu\":\" cc-noneu\";"
"var cur={"
"\"Europe/Stockholm\":\"sek\",\"Europe/Copenhagen\":\"dkk\",\"Europe/Warsaw\":\"pln\","
"\"Europe/Prague\":\"czk\",\"Europe/Budapest\":\"huf\",\"Europe/Bucharest\":\"ron\","
"\"Europe/Sofia\":\"bgn\","
"\"Europe/London\":\"gbp\",\"Europe/Zurich\":\"chf\",\"Europe/Oslo\":\"nok\","
"\"Atlantic/Reykjavik\":\"isk\",\"Asia/Tokyo\":\"jpy\","
"\"America/Toronto\":\"cad\",\"America/Vancouver\":\"cad\",\"America/Edmonton\":\"cad\","
"\"America/Winnipeg\":\"cad\",\"America/Halifax\":\"cad\",\"America/St_Johns\":\"cad\","
"\"America/Regina\":\"cad\",\"America/Moncton\":\"cad\",\"America/Whitehorse\":\"cad\","
"\"America/Yellowknife\":\"cad\",\"America/Iqaluit\":\"cad\","
"\"America/New_York\":\"usd\",\"America/Chicago\":\"usd\",\"America/Denver\":\"usd\","
"\"America/Los_Angeles\":\"usd\",\"America/Phoenix\":\"usd\",\"America/Anchorage\":\"usd\","
"\"America/Detroit\":\"usd\",\"America/Boise\":\"usd\",\"Pacific/Honolulu\":\"usd\","
"\"Australia/Sydney\":\"aud\",\"Australia/Melbourne\":\"aud\",\"Australia/Brisbane\":\"aud\","
"\"Australia/Perth\":\"aud\",\"Australia/Adelaide\":\"aud\",\"Australia/Hobart\":\"aud\","
"\"Australia/Darwin\":\"aud\",\"Pacific/Auckland\":\"nzd\"};"
"var c=cur[z];"
"document.addEventListener(\"DOMContentLoaded\",function(){"
"var els=document.querySelectorAll(\".price__single\");"
"for(var i=0;i<els.length;i++){"
"var v=c?els[i].getAttribute(\"data-\"+c):null;"
"if(!v&&!isEu)v=els[i].getAttribute(\"data-world\");"
"if(v)els[i].textContent=v;"
"}"
// The live checkout total. Reads only the data-cc blob the server rendered
// (unit prices per colour, zone rates, live carrier table) and mirrors
// ComputeTotals exactly: line total, floor((x*10000+6050)/12100) for the
// export net, shipping by country then zone. Same integers, same formula,
// so this preview and the charged amount cannot disagree.
"var f=document.querySelector(\"form[data-cc]\");"
"if(f){"
"var d=JSON.parse(f.getAttribute(\"data-cc\"));"
"var out=document.getElementById(\"cc-total\");"
"var ecc=[\"AT\",\"BE\",\"BG\",\"HR\",\"CY\",\"CZ\",\"DE\",\"DK\",\"EE\",\"ES\","
"\"FI\",\"FR\",\"GR\",\"HU\",\"IE\",\"IT\",\"LT\",\"LU\",\"LV\",\"MT\",\"NL\","
"\"PL\",\"PT\",\"RO\",\"SE\",\"SI\",\"SK\"];"
"var fmt=function(m){return m%100?\"\\u20ac\"+Math.floor(m/100)+\".\"+(\"0\"+m%100).slice(-2):\"\\u20ac\"+m/100};"
"var upd=function(){"
"var ce=f.querySelector(\"[name=color]\"),qe=f.querySelector(\"[name=quantity]\"),ke=f.querySelector(\"[name=country]\");"
"var unit=ce&&d.v[ce.value]?d.v[ce.value]:d.from;"
"var qty=qe?parseInt(qe.value,10)||1:1;"
"var k=(ke&&ke.value?ke.value:\"\").replace(/\\s/g,\"\").toUpperCase();"
"if(k.length!==2||!unit||qty<1||qty>d.q){if(out)out.hidden=true;return}"
"var eu=ecc.indexOf(k)>-1,line=unit*qty;"
"var goods=eu?line:Math.floor((line*10000+6050)/12100);"
"var ship=(d.c&&d.c[k])||(k===\"NL\"?d.s.nl:eu?d.s.eu:d.s.w);"
"if(out){out.textContent=\"You pay \"+fmt(goods+ship)+\" \\u2014 \"+fmt(goods)"
"+(qty>1?\" (\"+qty+\"\\u00d7)\":\"\")+\" + \"+fmt(ship)+\" shipping, \""
"+(eu?\"incl. VAT\":\"ex VAT\");out.hidden=false}"
"};"
"f.addEventListener(\"input\",upd);f.addEventListener(\"change\",upd);upd();"
"}"
"});"
"}catch(e){}})();"
"</script>\n";
// Wraps a rendered page in a complete HTML document.
//
// `bootScripts` is the <script> block Crafter.Build generated into the wasm
// bundle's index.html, passed through verbatim, or empty for a page that
// should not load the wasm at all. That per-route choice is what lets the
// shop later ship as plain HTML while /demo keeps the renderer.
export std::string RenderDocument(const RenderedPage& page,
const SafeHtml& nav,
const SafeHtml& footer,
std::string_view bootScripts,
std::string_view cssHref) {
const std::string canonical =
page.meta.canonical.empty()
? std::string{}
: Format(R"(<link rel="canonical"{}>)",
Url("href", "https://catcrafts.net" + page.meta.canonical)).Str();
// Open Graph + JSON-LD. og: tags are what the fediverse (and every chat
// app) renders as the link-preview card — and the fediverse is where this
// site's traffic actually comes from, so the cards matter more than
// usual. The JSON-LD block is inert data (type application/ld+json never
// executes), so shop pages keep their one-executable-script guarantee.
std::string headExtras;
headExtras += std::format("<meta property=\"og:title\"{}>\n",
Html::Attr("content", page.meta.title).Str());
if (!page.meta.description.empty()) {
headExtras += std::format("<meta property=\"og:description\"{}>\n",
Html::Attr("content", page.meta.description).Str());
}
headExtras += std::format("<meta property=\"og:type\"{}>\n",
Html::Attr("content", page.meta.ogType).Str());
headExtras += "<meta property=\"og:site_name\" content=\"Catcrafts\">\n";
if (!page.meta.canonical.empty()) {
headExtras += std::format("<meta property=\"og:url\"{}>\n",
Html::Attr("content", "https://catcrafts.net"
+ page.meta.canonical).Str());
}
if (!page.meta.ogImage.empty()) {
headExtras += std::format("<meta property=\"og:image\"{}>\n",
Html::Attr("content", "https://catcrafts.net"
+ page.meta.ogImage).Str());
headExtras += "<meta name=\"twitter:card\" content=\"summary_large_image\">\n";
}
if (!page.meta.jsonLd.empty()) {
headExtras += "<script type=\"application/ld+json\">"
+ page.meta.jsonLd + "</script>\n";
}
return std::format(
"<!doctype html>\n"
"<html lang=\"en\">\n<head>\n"
"<meta charset=\"utf-8\">\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 <base> 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.
"{}"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\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.
"<meta name=\"cc-ssr\" content=\"1\">\n"
"<title>{}</title>\n"
"{}{}{}{}{}"
"<link rel=\"alternate\" type=\"application/atom+xml\" title=\"Catcrafts\" href=\"/feed.xml\">\n"
"<link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n"
"<link rel=\"stylesheet\" href=\"{}\">\n"
"{}"
"</head>\n<body>\n"
"<div id=\"catcrafts-root\">\n"
"<header id=\"cc-header\">{}</header>\n"
"<main id=\"main\">{}</main>\n"
"<footer id=\"cc-footer\">{}</footer>\n"
"</div>\n"
"</body>\n</html>\n",
bootScripts.empty() ? "" : "<base href=\"/\">\n",
Html::Escape(page.meta.title).Str(),
page.meta.description.empty() ? std::string{}
: std::format("<meta name=\"description\"{}>\n",
Html::Attr("content", page.meta.description).Str()),
page.meta.noindex ? "<meta name=\"robots\" content=\"noindex, nofollow\">\n" : "",
page.meta.refreshSeconds > 0
? std::format("<meta http-equiv=\"refresh\" content=\"{}\">\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