catcrafts.net/shared/interfaces/Catcrafts.Shared-Views.cppm
Jorijn van der Graaf 413740af2e
All checks were successful
Deploy / build-deploy (push) Successful in 3m48s
text change
2026-08-21 04:52:25 +02:00

2510 lines
128 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 :Markdown;
import :Media;
import :Model;
import :Money;
import :Route;
namespace Catcrafts::Views {
using Html::SafeHtml;
using Html::Escape;
using Html::Autolink;
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><a{}>Financials</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"),
Url("href", "/financials"));
}
// ── 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>)",
// The on-site page when the post has one, the thread otherwise.
// Same rule as the cards on /posts: a link from the home page is
// worth more pointing at a page this site owns than at someone
// else's copy of it, and the post page links onward to the thread.
Url("href", post.HasPage() ? "/posts/" + post.slug : 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 )"
R"((Gen. 6) 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 (Gen. 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.
//
// The element itself — the format ladder, the dimensions, the poster — is
// :Media's job, shared with the body renderer so a post's headline screenshot
// and one embedded in its prose cannot be served differently.
SafeHtml RenderPostMedia(std::span<const PostMedia> media) {
return Media::Block(media);
}
// 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 RenderPostLinkRow(const Post& p) {
if (p.linkUrl.empty()) return SafeHtml{};
return Format(
R"(<p class="post-card__link"><a{} rel="nofollow noopener">{}</a></p>)",
Url("href", p.linkUrl), Escape(p.linkUrl));
}
// Points and the thread link, identical on a card and at the foot of a post
// page — the reader is being offered the same two things in both places, and
// they should not look like two different components.
SafeHtml RenderPostStats(const Post& p) {
return Format(
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>)",
Num(p.score), Url("href", p.permalink), Num(p.comments));
}
export RenderedPage RenderPosts(std::span<const Post> posts) {
std::vector<SafeHtml> cards;
for (const Post& p : posts) {
// The title goes to this site's copy when there is one. Before post
// pages existed it could only go to the thread, which meant the list
// of everything this site is about was a list of links off it.
const std::string titleHref = p.HasPage() ? "/posts/" + p.slug : p.permalink;
// No "read more" without a page to read more on — a post whose body
// never arrived would otherwise offer a link to its own 404.
//
// It goes inline at the end of the excerpt, immediately after the
// ellipsis the truncation left: that is where the sentence stops and
// where the reader is already looking for the rest of it. Below the
// media it was the same offer made a screen further down, after the
// reader had already decided.
const SafeHtml more = !p.HasPage() ? SafeHtml{} : Format(
R"( <a class="link-more"{}>Read the full post</a>)",
Url("href", "/posts/" + p.slug));
// With no excerpt there is no sentence to continue, so the link falls
// back to a row of its own rather than disappearing along with it.
const SafeHtml excerptRow =
!p.excerpt.empty()
? Format(R"(<p class="post-card__excerpt">{}{}</p>)", Escape(p.excerpt), more)
: more.Empty()
? SafeHtml{}
: Format(R"(<p class="post-card__more">{}</p>)", more);
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"({})"
R"(</article>)",
Url("href", titleHref), Escape(p.title),
Attr("datetime", p.published), Escape(DateOnly(p.published)),
Escape(p.community),
excerptRow,
RenderPostMedia(p.media),
RenderPostLinkRow(p),
RenderPostStats(p)));
}
RenderedPage page;
page.meta.title = "Posts — Catcrafts";
page.meta.description = "Posts about mobile Linux, kernel work and open hardware, "
"written for the fediverse and archived here in full.";
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 one is kept here in full, and links to the original thread on whichever )"
R"(instance it lives on — that is where the discussion is.</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;
}
// The post's video, as a schema.org VideoObject hung off the BlogPosting.
//
// Google already indexed and carouselled these videos on the strength of the
// bare <video> element; this makes the placement deliberate rather than lucky,
// and a typed thumbnailUrl is what earns the still on the text result too.
//
// thumbnailUrl and uploadDate are what the video rich result requires, so a
// video whose mirror produced no poster is skipped rather than emitted
// incomplete — the same both-or-neither rule Dimensions() applies to width and
// height. Only our own mirrored paths qualify, for the reason ogImage below
// gives: an absolute src or a third-party poster is the off-site fetch the
// mirror exists to avoid, just performed by a crawler.
//
// Headline media only. That IS the post — these posts are their recording —
// and it is the one video a search result should be pointing at.
//
// No encodingFormat and no duration: neither is required, the mirror records no
// duration, and naming a container guessed from a file extension would assert
// something we do not know. A wrong type is worse than a missing one, for the
// same reason MimeFor() refuses to guess.
std::string PostVideoLd(const Post& p) {
std::vector<std::string> videos;
for (const PostMedia& m : p.media) {
if (m.kind != "video") continue;
if (!m.src.starts_with("/") || !m.poster.starts_with("/")) continue;
std::string node = std::format(
R"({{"@type":"VideoObject","name":{},"uploadDate":{},)"
R"("thumbnailUrl":{},"contentUrl":{})",
JsonStr(p.title), JsonStr(p.published),
JsonStr("https://catcrafts.net" + m.poster),
JsonStr("https://catcrafts.net" + m.src));
if (!p.excerpt.empty()) node += ",\"description\":" + JsonStr(p.excerpt);
if (m.width > 0 && m.height > 0) {
node += std::format(R"(,"width":{},"height":{})", m.width, m.height);
}
node += "}";
videos.push_back(std::move(node));
}
if (videos.empty()) return {};
if (videos.size() == 1) return ",\"video\":" + videos.front();
std::string out = ",\"video\":[";
for (std::size_t i = 0; i < videos.size(); ++i) {
if (i) out += ',';
out += videos[i];
}
out += ']';
return out;
}
// ── one post, in full ─────────────────────────────────────────────────
// The whole point of hosting the body: a page a search engine can index, a
// reader can link to, and this site can claim as canonical. The card on /posts
// is a summary of this; the thread on the instance is where the comments are.
//
// The body arrives as Markdown and is rendered HERE rather than converted at
// fetch time, because :Markdown is inside the escaping guarantee and a shell
// script writing HTML into a content file would not be. See that module for
// what it does and does not accept.
export RenderedPage RenderPost(const Post& p) {
// Whatever the post leads with, for the link-preview card. A video has no
// still of its own to offer, so its poster frame stands in. Only our own
// mirrored copies qualify: an og:image on someone else's instance is the
// same third-party fetch the mirror exists to avoid, just performed by a
// crawler instead of a reader.
std::string ogImage;
for (const PostMedia& m : p.media) {
const std::string& candidate = m.kind == "video" ? m.poster : m.src;
if (candidate.starts_with("/")) { ogImage = candidate; break; }
}
const std::string canonical = "/posts/" + p.slug;
RenderedPage page;
page.meta.title = p.title + " — Catcrafts";
page.meta.description = p.excerpt;
page.meta.canonical = canonical;
page.meta.ogType = "article";
page.meta.ogImage = ogImage;
// BlogPosting, joined to the same two nodes every other page names: the
// Person on /about is the author and the Organization is the publisher.
// Without those @ids each post would introduce a fourth unrelated
// "Catcrafts" to a search index that already has trouble telling this one
// from the name-twins — see RenderHome for the whole argument.
//
// discussionUrl is the honest way to say what the fediverse link is: the
// comments belong to the thread, and this page is not pretending to mirror
// them.
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"BlogPosting",)"
R"("headline":{},"datePublished":{},"url":{},)"
R"("mainEntityOfPage":{{"@type":"WebPage","@id":{}}},)"
R"("author":{{"@id":"https://catcrafts.net/about#person",)"
R"("@type":"Person","name":"Jorijn van der Graaf"}},)"
R"("publisher":{{"@id":"https://catcrafts.net/#organization",)"
R"("@type":"Organization","name":"Catcrafts"}},)"
R"("discussionUrl":{}{}{}{}}})",
JsonStr(p.title), JsonStr(p.published),
JsonStr("https://catcrafts.net" + canonical),
JsonStr("https://catcrafts.net" + canonical),
JsonStr(p.permalink),
p.excerpt.empty() ? std::string{} : ",\"description\":" + JsonStr(p.excerpt),
ogImage.empty() ? std::string{}
: ",\"image\":" + JsonStr("https://catcrafts.net" + ogImage),
PostVideoLd(p));
page.main = Format(
R"(<article class="post">)"
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="post-card__meta">)"
R"(<time{}>{}</time><span class="post-card__community">{}</span>)"
R"(</p>)"
R"(</header>)"
R"({})"
R"({})"
R"(<div class="post-body">{}</div>)"
R"({})"
R"(<p class="post__back"><a class="link-more"{}>All posts</a></p>)"
R"(</article>)",
Escape(p.title),
Attr("datetime", p.published), Escape(DateOnly(p.published)),
Escape(p.community),
RenderPostMedia(p.media),
RenderPostLinkRow(p),
Markdown::Render(p.body, p.bodyMedia),
RenderPostStats(p),
Url("href", "/posts"));
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),
// A donation has no price to quote — the card says so instead of
// rendering a €0 that the checkout would never charge.
p.donation && p.Buyable()
? Raw(R"(<p class="price price--card">any amount</p>)")
: 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 (Gen. 6) and (Gen. 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. Or fund the work directly: the donation item )"
R"(takes any amount.)"
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 payment choice, shared by the checkout form and the donation form so
// the two can never describe the same rails differently. A radio group rather
// than a <select> because both options carry a sentence the buyer should read
// BEFORE choosing — one settles in euro from their bank, the other locks a
// euro price against a coin — and a collapsed dropdown hides exactly that. It
// also needs no JavaScript, like everything else in these forms.
//
// Bank is pre-selected WHEN IT IS OFFERED: it is what nearly every buyer
// wants, and an unselected group would let a distracted submit land on
// neither.
//
// `offerBank` is the mirror of the `offerCrypto` that gates this whole
// fieldset, and it defaults to true so every caller that cannot know (the
// wasm fallback page, the suites) keeps the two-option form it always had.
// It exists because the asymmetry was a real outage: when the bank rail went
// away on 2026-08-20 the form went on rendering a pre-selected "Bank or card"
// option that checkout could only answer with a 503, which is the majority of
// buyers walking into a wall. With it false the bank option is not rendered at
// all, so the crypto radio is the only one present AND is checked — the form
// must still POST a `pay` value, because an absent one resolves to the bank
// rail by design.
SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError,
bool offerBank = true) {
// With one option left there is nothing to choose, so it is pre-selected
// regardless of what the buyer picked on a previous, rejected submit.
const bool wantsCrypto = !offerBank || prev.payChoice == Form::kPayCrypto;
// This copy has to describe whatever rail is actually in the bank slot,
// and today that is the self-hosted transfer rail: a plain SEPA transfer
// to the shop's own account, with the details on the order page. It
// deliberately does NOT promise iDEAL or cards, which is what it said
// while a hosted provider served the slot. Promising a method the rail
// cannot take is the same class of bug as offering a rail that is not
// configured: the buyer finds out at the last step.
const SafeHtml bankOption = offerBank
? Format(
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Bank transfer</strong><br>Regular IBAN bank transfer.</span></label>)",
Attr("value", std::string(Form::kPayBank)),
wantsCrypto ? SafeHtml{} : Raw(" checked"))
: SafeHtml{};
return Format(
R"(<fieldset class="field field--pay">)"
R"(<legend>How you want to pay</legend>)"
R"({})"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Cryptocurrency</strong><br>EURC, a euro )"
R"(stablecoin. The amount to send is the )"
R"(euro total exactly with no exchange rate.</span></label>)"
R"({})"
R"(</fieldset>)",
bankOption,
Attr("value", std::string(Form::kPayCrypto)),
wantsCrypto ? Raw(" checked") : SafeHtml{},
payError);
}
// 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 -> weight-bracket ladder)
// when the server has one; empty otherwise. It feeds the data-cc blob below so
// the on-page total preview picks the exact bracket checkout will charge — and
// refuses in exactly the places checkout refuses, since with no zone fallback
// left there are now destinations and quantities that have no price at all.
// `offerCrypto` renders the payment-method choice. It is false whenever the
// crypto rail is not configured — and on the wasm fallback page, which cannot
// know — so the form only ever advertises a way to pay that the server can
// actually serve. With it false the form posts no `pay` field at all and the
// handler takes the bank rail, which is exactly the behaviour that existed
// before there was anything to choose.
// `offerBank` is the same promise for the other slot, and defaults to true so
// the callers that cannot know keep their previous behaviour. See
// RenderPayFieldset for why the asymmetry had to be closed.
SafeHtml RenderCheckoutForm(const Product& product,
std::span<const Money::ShipRates> liveShipping,
std::span<const Form::FieldError> errors,
const Form::Checkout& prev,
bool offerCrypto,
bool offerBank = true) {
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{}{}>{} {}</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, the boxed unit weight, and the
// live carrier table as country -> [[maxGrams, cents], …]. The script
// multiplies, picks a bracket 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":{},"g":{},"c":{{)",
product.priceInclMinor, product.shipWeightGrams);
for (std::size_t i = 0; i < liveShipping.size(); ++i) {
cc += std::format(R"({}"{}":[)", i ? "," : "", liveShipping[i].cc);
for (std::size_t b = 0; b < liveShipping[i].brackets.size(); ++b) {
cc += std::format("{}[{},{}]", b ? "," : "",
liveShipping[i].brackets[b].maxWeightGrams,
liveShipping[i].brackets[b].minor);
}
cc += ']';
}
// The two shipping refusals, as the same {cc}/{n} templates the handler
// fills for its field errors — so the page cannot word a refusal
// differently from the one that follows a submit.
cc += std::format(R"(}},"q":{},"nm":{},"hm":{},"hm0":{})",
Form::kMaxQuantity,
JsonStr(Form::kNoShippingTemplate),
JsonStr(Form::kTooHeavyTemplate),
JsonStr(Form::kTooHeavyNoneTemplate));
// The destinations checkout refuses, and the sentences that say so — the
// policy list (x/xm) and the sanctions list (s/sm), each with its own
// wording. The preview has to refuse exactly where the server does — a
// page that quotes a total for an order the server will reject is worse
// than one that never quoted it.
// The most units any destination's heaviest bracket can carry, clamped to
// the parsing ceiling. With no table (the wasm fallback path) this stays at
// kMaxQuantity — that page cannot quote a total or reach checkout anyway,
// so narrowing its input would be theatre.
std::int64_t bestUnits = 0;
for (const Money::ShipRates& r : liveShipping) {
bestUnits = std::max(bestUnits,
Money::MaxUnitsFor(r.brackets, product.shipWeightGrams));
}
if (bestUnits <= 0 || bestUnits > Form::kMaxQuantity) bestUnits = Form::kMaxQuantity;
// Sanctions (s/sm) then the shipping allow-list (w/rm). Note w is what the
// shop CAN ship to, so the preview refuses on absence — which is why this
// payload stays five codes long while most of the world is closed, instead
// of carrying a deny-list of two hundred.
cc += R"(,"s":[)";
for (std::size_t i = 0; i < Money::SanctionedCountries().size(); ++i) {
if (i) cc += ',';
cc += JsonStr(Money::SanctionedCountries()[i]);
}
cc += std::format(R"(],"sm":{},"w":[)", JsonStr(Form::kSanctionsMessage));
for (std::size_t i = 0; i < Money::ShippableCountries().size(); ++i) {
if (i) cc += ',';
cc += JsonStr(Money::ShippableCountries()[i]);
}
cc += std::format(R"(],"rm":{}}})", JsonStr(Form::kRegulatoryMessage));
const SafeHtml payFieldset =
offerCrypto ? RenderPayFieldset(prev, errorFor("pay"), offerBank)
: SafeHtml{};
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{}. Nothing is owed until you actually pay; )"
R"(an unpaid order just lapses. The address is used to ship this order and )"
R"(for the invoice, 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. The max is the BEST case across
// destinations (see bestUnits above): the real ceiling depends on the
// country's heaviest carrier bracket, so a stricter number here would
// block orders that are perfectly shippable somewhere else. The preview
// narrows it as soon as a country is typed, and the handler enforces it.
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. No US or CA, and no sanctioned countries — see the terms.</p>)"
R"({})"
R"(</div>)"
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"(<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 continue to payment</button>)"
R"(</form>)"
R"(</section>)",
// With the choice rendered below, the fieldset lists the methods and
// the lede would only repeat half of them.
offerCrypto ? SafeHtml{}
: Raw(", paid by bank transfer"),
Escape(Form::kShipsToMessage),
Escape(Form::kSanctionsMessage),
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(bestUnits)),
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"),
payFieldset);
}
// The donation form: the checkout form's small sibling. An amount instead of
// a price, an OPTIONAL email instead of a shipping address — nothing ships,
// so nothing more is asked for (the privacy notice's "what fulfilling it
// requires" rule, applied to a gift). Same POST target, same honeypot, same
// payment fieldset, same no-JavaScript guarantee.
SafeHtml RenderDonationForm(const Product& product,
std::span<const Form::FieldError> errors,
const Form::Checkout& prev,
bool offerCrypto,
bool offerBank = true) {
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{};
};
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;
}
}
return Format(
R"(<section class="checkout" id="buy">)"
R"(<h2 class="section__title">Donate</h2>)"
R"(<p class="checkout__lede">Pick any amount{}. Submitting creates the )"
R"(donation and takes you straight to the payment page. Nothing is owed )"
R"(until you actually pay; an unpaid donation just lapses.</p>)"
R"(<p class="checkout__shipnote">No VAT is charged on a donation and no )"
R"(invoice is issued. The donation page is its receipt. Donations )"
R"(appear on the financials page as an aggregate total, never )"
R"(individually.</p>)"
R"({})"
R"(<form class="form" method="post"{} novalidate>)"
R"(<div class="field">)"
R"(<label for="f-amount">Amount in euros <span class="field__req">required</span></label>)"
R"(<input id="f-amount" name="amount" type="number" inputmode="decimal" )"
R"(min="1" max="10000" step="0.01" required{}>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-email">Email</label>)"
R"(<input id="f-email" name="email" type="email" autocomplete="email"{}>)"
R"(<p class="field__hint">Optional: only used to send the )"
R"(confirmation. Leave it empty and the donation page is your receipt.</p>)"
R"({})"
R"(</div>)"
R"({})"
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>)"
R"(<button class="btn btn--primary" type="submit">Donate</button>)"
R"(</form>)"
R"(</section>)",
offerCrypto ? SafeHtml{}
: Raw(", paid by a plain bank transfer"),
formError,
Url("action", "/shop/" + product.slug + "#buy"),
prev.amountMinor > 0
? Attr("value", Money::FormatMinor(prev.amountMinor)) : SafeHtml{},
errorFor("amount"),
Attr("value", prev.email), errorFor("email"),
offerCrypto ? RenderPayFieldset(prev, errorFor("pay"), offerBank)
: SafeHtml{});
}
// `offerCrypto` reaches the checkout form; see RenderCheckoutForm for why it
// defaults to false. Only the native server passes it true, because only the
// server knows whether the crypto rail is configured. `offerBank` is the same
// fact about the other slot and defaults to TRUE rather than false, because a
// caller that cannot know must keep advertising the rail that has always been
// there — see RenderPayFieldset.
export RenderedPage RenderProduct(const Product& product,
const Rates& rates,
std::span<const Money::ShipRates> liveShipping = {},
std::span<const Form::FieldError> errors = {},
const Form::Checkout& prev = {},
bool offerCrypto = false,
bool offerBank = true) {
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;
// The donation page shows no converted prices — there is no price — so it
// ships no price-hint script either, keeping it entirely script-free.
page.meta.geoPriceHint = !product.donation;
// 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.
//
// A donation emits none of it: it has no price, no shipping and no return
// policy, and a Product record whose offer names no amount is a claim
// shopping crawlers can only misread.
//
// 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 is published from the live carrier table
// at single-unit weight, the same integers checkout charges, so the listing
// and the till cannot disagree; a destination with no carrier rate is
// simply not advertised, because it is not for sale.
if (!product.donation) {
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";
// Every destination this listing may advertise: the carrier has a rate
// for a single boxed unit, and the shop is willing to sell there.
// US and CA drop out by policy and the sanctioned countries by law,
// not oversight (Money::SellsTo denies both): listing a shipping rate
// to a country checkout refuses would publish an offer that cannot be
// accepted, and feed it to shopping crawlers as an invitation to buy
// from there. Everywhere else drops out because no carrier rate
// exists — which is now the same sentence.
struct FeedDest { std::string cc; std::int64_t rate; Money::Zone zone; };
std::vector<FeedDest> dests;
for (const Money::ShipRates& r : liveShipping) {
if (!Money::SellsTo(r.cc)) continue;
const std::int64_t rate = Money::RateFor(r.brackets, product.shipWeightGrams);
if (rate > 0) dests.push_back({ r.cc, rate, Money::ZoneFor(r.cc) });
}
// One OfferShippingDetails per (transit tier, price) — the rates are
// real per-country carrier prices now, so the grouping is whatever the
// carrier's pricing happens to be rather than three tiers decided here.
// Transit times still key on distance because Sendcloud's method list
// carries no delivery estimate to read.
std::vector<std::pair<std::pair<Money::Zone, std::int64_t>, std::string>> groups;
for (const FeedDest& d : dests) {
const auto key = std::make_pair(d.zone, d.rate);
auto at = std::ranges::find(groups, key, &decltype(groups)::value_type::first);
if (at == groups.end()) {
groups.push_back({ key, JsonStr(d.cc) });
} else {
at->second += ',' + JsonStr(d.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);
};
// Empty when there is no rate table — the wasm fallback render, or a
// server that has never reached Sendcloud. Publishing nothing is right:
// the alternative is inventing a shipping price for a feed, which is
// the exact claim this shop can no longer make.
std::string shippingDetails;
for (const auto& [key, list] : groups) {
const auto [zone, rate] = key;
const int tmin = zone == Money::Zone::Nl ? 1 : zone == Money::Zone::Eu ? 2 : 5;
const int tmax = zone == Money::Zone::Nl ? 2 : zone == Money::Zone::Eu ? 5 : 14;
if (!shippingDetails.empty()) shippingDetails += ',';
shippingDetails += shipTier(Money::FormatMinor(rate), list, tmin, tmax);
}
if (!shippingDetails.empty()) shippingDetails = "[" + shippingDetails + "]";
// 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. Both
// lists name the destinations actually being offered, so the return
// terms cover exactly the countries the shipping block advertises.
std::string euAll, worldList;
for (const FeedDest& d : dests) {
std::string& into = Money::IsEuCountry(d.cc) ? euAll : worldList;
if (!into.empty()) into += ',';
into += JsonStr(d.cc);
}
// Each half is emitted only if some offered destination falls under it
// — an "applicableCountry":[] policy states a rule that applies to
// nobody, which is worse than staying silent.
std::string returnPolicy;
if (!euAll.empty()) {
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"}})",
euAll);
}
if (!worldList.empty()) {
if (!returnPolicy.empty()) returnPolicy += ',';
returnPolicy += std::format(
R"({{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
R"("returnPolicyCategory":"https://schema.org/MerchantReturnNotPermitted"}})",
worldList);
}
if (!returnPolicy.empty()) returnPolicy = "[" + returnPolicy + "]";
// Both blocks describe destinations, so both disappear together when
// there are none to describe.
const std::string fulfilment =
shippingDetails.empty() && returnPolicy.empty()
? std::string{}
: std::format(R"(,"shippingDetails":{},"hasMerchantReturnPolicy":{})",
shippingDetails.empty() ? "[]" : shippingDetails,
returnPolicy.empty() ? "[]" : returnPolicy);
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"}}{}}})",
availability, JsonStr(productUrl), fulfilment);
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 emergency calls.</strong> {}</p>)",
Escape(product.safetyNote));
SafeHtml buy;
if (product.donation && product.Buyable()) {
buy = RenderDonationForm(product, errors, prev, offerCrypto, offerBank);
} else if (product.Buyable()) {
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto,
offerBank);
} 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>{} {}</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>)");
}
// The spec and warranty sections exist only where the content does: a
// donation has neither a spec sheet nor a warranty, and an empty table
// under a manufacturer-specific lede would be nonsense on its page.
//
// The lede names the BRAND, never a model. It used to say "Fairphone
// (Gen. 6)" in the markup, which read true while the catalogue held one
// phone and went silently false the moment it held two — a spec sheet
// introduced as some other device's sheet is worse than no lede at all.
// A spec sheet with no brand to name is a content bug, asserted against in
// ShouldShipContent rather than papered over with a second sentence here.
const SafeHtml specsSection = product.specs.empty() ? SafeHtml{} : Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Specifications</h2>)"
R"(<p class="section__lede">The hardware is a stock {}, unmodified. )"
R"({}'s spec sheet is this product's spec sheet, )"
R"(and all of it works under postmarketOS.</p>)"
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
R"(</section>)",
Escape(product.brand), Escape(product.brand), Join(specRows));
const SafeHtml warrantySection = product.warranty.empty() ? SafeHtml{} : Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Warranty</h2>)"
R"(<p>{}</p>)"
R"(</section>)",
Escape(product.warranty));
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"({})"
R"({})"
R"({})",
Escape(product.name), Escape(product.tagline),
media,
product.donation ? SafeHtml{} : RenderPriceLine(product, rates),
Escape(product.summary),
safety, specsSection,
warrantySection,
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.
//
// An awaiting order whose payment has been SEEN in flight says so in the
// badge itself, not only in the note below: the badge is where a buyer
// who just paid looks first, and "awaiting payment" there reads as "your
// money did not arrive" no matter what a paragraph underneath explains.
// `seen` means different things on the two rails, so the badge must not
// use one wording for both. On the crypto rail it means the transfer is
// visible on chain but not yet in a finalized block. On the bank rail it
// means money arrived that does NOT cover the order — a part payment —
// and telling that buyer about "network confirmation" would be nonsense
// about a mechanism their bank transfer never touches. What they need to
// know is that their money landed and what is still outstanding.
const bool inFlight = awaiting && o.cryptoPay && o.cryptoPay->seen;
const bool inFlightBank = inFlight && !o.cryptoPay->beneficiary.empty();
SafeHtml statusLine =
inFlightBank ? Raw(R"(<span class="badge badge--experiment">part payment received, waiting for the balance</span>)")
: inFlight ? Raw(R"(<span class="badge badge--experiment">payment detected, awaiting network confirmation</span>)")
: 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
// to the payment instructions, and both live rails render those on this
// very page. Reaching the awaiting state therefore just means the money
// has not arrived yet, so it reads as "here is how to pay", not as an
// alarming limbo.
SafeHtml payBlock;
// Self-hosted crypto first: its payUrl is this very page, so the button
// branch would render a link to where the buyer already stands.
if (awaiting && o.cryptoPay) {
const OrderCryptoPay& pay = *o.cryptoPay;
// One list item per network, in the given order — the first entry is
// the recommendation, which is how "cheapest chain first" reaches the
// buyer as layout rather than as a lecture.
std::vector<SafeHtml> items;
for (const OrderCryptoPay::Chain& c : pay.chains) {
std::string label = c.name;
if (!label.empty() && label[0] >= 'a' && label[0] <= 'z') {
label[0] = static_cast<char>(label[0] - 'a' + 'A');
}
items.push_back(Format(
R"(<li><strong>{}</strong>{}{}<br>)"
R"(<small>EURC contract <code>{}</code></small></li>)",
Escape(label),
c.note.empty() ? SafeHtml{} : Format(" {}", Escape(c.note)),
c.link.empty() ? SafeHtml{}
: Format(R"( &middot; <a{}>open in your wallet</a>)",
Url("href", c.link)),
Escape(c.contract)));
}
// The window line tells the truth for both signs of minutesLeft. A
// closed window does NOT mean sent money is gone — the address stays
// ours — so the copy says where it went instead of leaving a buyer
// staring at money that "vanished". The open branch also owns the
// expectation the first live payment proved buyers need: a wallet
// says "success" within seconds, this shop only believes finalized
// blocks, and a buyer left to discover that ~15-minute gap alone
// discovers the support address instead.
// Same two truths for either rail, in each rail's own words. The
// lapsed branch matters more than it looks: in BOTH cases the
// destination stays ours, so money already sent is not lost, and a
// buyer told only "the window closed" would reasonably conclude it
// was. The transfer wording also has to survive the case where the
// money is simply slow — a non-instant transfer from outside the euro
// area can arrive after the window on its own.
const bool bank = !pay.beneficiary.empty();
const SafeHtml windowLine = pay.minutesLeft > 0
? (bank
? Format(
R"(<p class="order__note">This order is held for about {} )"
R"(more {}. Transfers inside the Netherlands usually arrive )"
R"(within seconds, elsewhere in Europe it can take a )"
R"(business day. This page checks automatically and confirms )"
R"(as soon as the money lands, so there is nothing to send )"
R"(us and nothing to wait for here.</p>)",
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
: Format(
R"(<p class="order__note">This address is reserved for this order )"
R"(for about {} more {}. This page checks automatically and )"
R"(confirms once the full amount has arrived and the network has )"
R"(finalized it: your wallet will report success well before then, )"
R"(and confirmation here typically follows in 10 to 25 minutes.</p>)",
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes")))
: (bank
? Raw(R"(<p class="order__note">The payment window for this order has )"
R"(closed and the order will lapse. If you already sent the )"
R"(transfer it is not lost: the account above is ours and the )"
R"(money arrived there. Contact )"
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
R"(with your order reference and it will be settled by hand.</p>)")
: Raw(R"(<p class="order__note">The payment window for this order has )"
R"(closed and the order will lapse. If you already sent EURC it )"
R"(is not lost: it arrived at the address above; contact )"
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
R"(and it will be settled by hand.</p>)"));
// The in-flight state renders as the status badge up top
// ("confirming payment"), where a buyer who just paid looks first;
// this block keeps only the standing instructions.
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
R"(<p class="order__indicative">{}, indicative only. The charge is )"
R"(the euro amount above.</p>)",
Escape(indicative));
// A non-empty beneficiary marks a BANK TRANSFER: the address is an
// IBAN, there are no networks, and none of the token copy applies.
// Both live rails are self-hosted and so both land in this branch;
// what separates them is which of these two blocks renders.
if (!pay.beneficiary.empty()) {
// Three fields, in the order a banking app asks for them, so the
// buyer can work straight down the page instead of hunting.
//
// The name comes FIRST and is labelled as exact on purpose. Every
// euro-area transfer is now name-checked against the IBAN, and a
// payer who types anything else gets a mismatch warning at the
// moment of paying. Telling them why the spelling looks odd is
// cheaper than losing the payment to a scary red banner.
//
// Both reference forms are offered because banks disagree about
// where a reference goes: those with a dedicated payment-reference
// field validate the RF form's check digits and refuse a mistyped
// one before the money moves, which is the safer path; the rest
// only have a free-text description, where the short code is what
// a human will actually copy correctly.
payBlock = Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Pay by bank transfer</h2>)"
R"(<p>Transfer <strong>{}</strong> to this account:)"
R"(<dl class="order__bank">)"
R"(<dt>Account holder</dt><dd><code class="order__address">{}</code></dd>)"
R"(<dt>IBAN</dt><dd><code class="order__address">{}</code></dd>)"
R"({})"
R"(<dt>Payment reference</dt><dd><code class="order__address">{}</code></dd>)"
R"(</dl>)"
R"({})"
R"(<p class="order__note">Please copy the the fields )"
R"(exactly as written above. If your bank )"
R"(has a separate field for a payment reference, use )"
R"(<strong>{}</strong> there, it is checked for typing errors. )"
R"(Otherwise put <strong>{}</strong> in the description. )"
R"(Without the reference the payment cannot be matched to your )"
R"(order. If you send too little, transfer the rest the same )"
R"(way and the order confirms once the total arrives.</p>)"
R"({})"
R"(</section>)",
Escape(Money::FormatEuro(o.totalMinor)),
Escape(pay.beneficiary),
Escape(pay.address),
// Only for the payer who actually needs it. Inside SEPA the
// IBAN is enough, so this row is absent rather than being a
// field every Dutch buyer feels obliged to fill in.
pay.bic.empty() ? SafeHtml{} : Format(
R"(<dt>BIC <span class="order__bank-hint">(only if your bank )"
R"(asks for it, usually outside Europe)</span></dt>)"
R"(<dd><code class="order__address">{}</code></dd>)",
Escape(pay.bic)),
Escape(pay.structuredReference.empty() ? o.reference
: pay.structuredReference),
indicativeLine,
Escape(pay.structuredReference.empty() ? o.reference
: pay.structuredReference),
Escape(o.reference),
windowLine);
} else {
payBlock = Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Pay with EURC</h2>)"
R"(<p>Send <strong>{} EURC</strong> to this address. One )"
R"(network, one payment:</p>)"
R"(<p><code class="order__address">{}</code></p>)"
R"(<ul class="order__chains">{}</ul>)"
R"({})"
R"(<p class="order__note">EURC is pegged to the euro, so the amount )"
R"(is exactly the euro total, no exchange rate. Send EURC only, )"
R"(and only on a network listed above. If your exchange deducts a )"
R"(withdrawal fee, send the missing rest to the same address; the )"
R"(order confirms once the full amount sits on one network. Your )"
R"(order reference is <strong>{}</strong>.</p>)"
R"({})"
R"(</section>)",
Escape(pay.amount),
Escape(pay.address),
Join(items),
indicativeLine,
Escape(o.reference),
windowLine);
}
} else if (awaiting && !o.payUrl.empty()) {
const bool crypto = o.payChoice == Form::kPayCrypto;
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));
// What is waiting behind the button differs by rail. The real crypto
// rail never reaches this branch (its instructions render above); a
// crypto choice here means a hosted stand-in — the fake rail in tests
// — so its copy stays generic rather than naming coins.
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 {}</a></p>)"
R"(<p class="order__note">{} your order reference is <strong>{}</strong>. )"
R"(If you just paid, this page confirms it within seconds. {} Nothing )"
R"(is owed.</p>)"
R"(</section>)",
indicativeLine,
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
// Both live rails render their instructions above rather than a
// button, so reaching this branch means a HOSTED stand-in is in
// the slot: the fake rails in the suites, or the reference
// a hosted rail, if one is ever added again. The copy therefore
// stays generic about methods instead of naming any, since what
// waits behind the button is exactly what this branch cannot know.
crypto ? Raw("The payment page completes your crypto payment;")
: Raw("The payment page completes your payment;"),
Escape(o.reference),
Raw("A payment left uncompleted simply lapses the order."));
} else if (o.status == "paid") {
// A donation ships nothing and gets no invoice — a gift with nothing
// supplied in return is not a taxable supply — so its paid state is a
// thank-you, not a dispatch promise with a download button.
payBlock = o.donation
? Raw(R"(<section class="section"><h2 class="section__title">Thank you</h2>)"
R"(<p>Your donation funds the open-source work directly. It will )"
R"(appear in the running total on the financials page as )"
R"(an aggregate, never individually. This page is your receipt; )"
R"(no invoice is issued for a donation.</p>)"
R"(</section>)")
: 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{};
// A donation's money is one line — an amount with nothing shipped adds no
// shipping row and needs no separate total. Goods orders keep the full
// breakdown.
const SafeHtml moneyRows = o.donation
? MoneyRow("Donation", o.totalMinor)
: Format(R"({}{}{})",
MoneyRow(o.quantity > 1
? std::format("Device × {}", o.quantity)
: std::string("Device"), o.goodsMinor),
MoneyRow("Shipping", o.shippingMinor),
MoneyRow("Total", o.totalMinor));
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"(</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"({} 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,
moneyRows,
o.donation
// The user's rule, stated plainly: 0% — a donation is a gift, not
// a supply, so no VAT arises and neither export wording applies.
? Raw("VAT 0%: no VAT is charged on a donation — nothing is "
"supplied in return.")
: 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,
o.donation ? Raw("Keep it if you want the receipt.")
: Raw("Download the invoice and keep it."));
// 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) {
// Autolink, not Escape: the privacy notice points the reader at
// /analytics and at its own edit history, and a policy that says
// "go look for yourself" should not make them retype the address.
paras.push_back(Format(R"(<p>{}</p>)", Autolink(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) {
// Same prose shape as the legal pages, so the same treatment —
// otherwise a URL added here later silently renders as dead text.
paras.push_back(Format(R"(<p>{}</p>)", Autolink(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;
}
// ── financials ────────────────────────────────────────────────────────
// The open-financials page: the company's money as live running totals.
//
// The privacy design is structural, not editorial. This renderer can only
// ever see aggregates: sales arrive as two integers folded out of the order
// ledger, donations and expenses arrive as category totals from the
// bank-aggregates file. No transaction, timestamp or counterparty exists in
// either input, so no future edit here can accidentally publish one.
//
// The data-fin-* attributes are the machine-readable copy of the figures —
// what the e2e suite asserts against, and what anyone scraping the page in
// good faith should read instead of parsing euro signs.
//
// Donations arrive from TWO ledgers: the bank aggregates in `fin`, and the
// shop's own order ledger (`shopDonationCount`/`shopDonationsMinor`) — the
// donation item is paid through the same rails as a sale, so its money never
// touches the bank categoriser. The page shows one Donations row summing
// both; splitting them by collection channel would be bookkeeping trivia the
// reader has no use for.
export RenderedPage RenderFinancials(std::int64_t salesCount,
std::int64_t salesTotalMinor,
const Financials& fin,
std::int64_t shopDonationCount = 0,
std::int64_t shopDonationsMinor = 0) {
const LegalPage& notes = Content::FinancialsPage();
// A total row is ruled off from the rows it sums, the way a ledger is.
auto totalRow = [](std::string_view label, std::int64_t minor) {
return Format(
R"(<tr class="fin-total"><th scope="row">{}</th><td class="order__amount">{}</td></tr>)",
Escape(label), Escape(Money::FormatEuro(minor)));
};
// Income. Sales are always live; the donation row exists once EITHER
// source has figures — the bank aggregates, or a donation paid through
// the shop (live from the order ledger, like sales). Before both, a €0
// the page cannot yet know would be a lie, and so would an income total
// missing half its inputs.
const std::int64_t donationCount = fin.donationCount + shopDonationCount;
const std::int64_t donationsMinor = fin.donationsMinor + shopDonationsMinor;
const bool showDonations = fin.Loaded() || shopDonationCount > 0;
std::vector<SafeHtml> incomeRows;
if (showDonations) {
incomeRows.push_back(MoneyRow(
std::format("Donations ({})", donationCount),
donationsMinor));
}
incomeRows.push_back(MoneyRow(
std::format("Sales ({})", salesCount),
salesTotalMinor));
if (fin.Loaded()) {
incomeRows.push_back(totalRow("Income", donationsMinor + salesTotalMinor));
}
// Expenses: one flat table. No recurring/one-off grouping — see the note
// on Financials::expenses for why a lifetime total cannot carry a rate.
SafeHtml expenses;
if (fin.Loaded()) {
std::vector<SafeHtml> rows;
for (const FinCategory& c : fin.expenses) rows.push_back(MoneyRow(c.label, c.totalMinor));
rows.push_back(totalRow("Expenses", fin.ExpensesMinor()));
expenses = Format(R"(<table class="spec-table"><tbody>{}</tbody></table>)",
Join(rows));
} else {
expenses = Raw(
R"(<p class="notice">Donations and expenses are aggregated from the )"
R"(business bank account and have not been published yet. The sales )"
R"(figures above are already live.</p>)");
}
// Net: what is actually left. Shown only when the bank figures exist —
// income minus an unknown expense side is not a net of anything, and a
// figure equal to sales while expenses are unpublished would read as a
// company with no costs.
//
// Deliberately NOT called profit. On a cash basis this ignores stock
// still on the shelf, anything owed in either direction, and tax not yet
// paid, so calling it profit would be a claim the arithmetic cannot
// support. FormatEuro renders a negative as "€-12.34", which is the
// honest thing to show in a month that bought inventory.
const std::int64_t netMinor =
donationsMinor + salesTotalMinor - fin.ExpensesMinor();
SafeHtml netBlock;
if (fin.Loaded()) {
netBlock = Format(
R"(<section class="section"><h2 class="section__title">Net</h2>)"
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
R"(<p class="section__lede">On the same cash basis as everything above. )"
R"(This is not profit: it counts no stock still on the shelf, nothing )"
R"(owed in either direction, and no tax.</p>)"
R"(</section>)",
// The row says the arithmetic rather than repeating the heading —
// a section titled "Net" whose only row is also "Net" reads as a
// rendering fault, and the sum is worth spelling out anyway.
totalRow("Income expenses", netMinor));
}
// The freshness line keeps the page honest about its two cadences.
const SafeHtml freshness = fin.Loaded()
? Format(R"(<p class="legal__updated">Sales and shop donations are live )"
R"(from the order ledger &middot; )"
R"(bank figures as of <time{}>{}</time></p>)",
Attr("datetime", fin.asOf), Escape(fin.asOf))
: Raw(R"(<p class="legal__updated">Sales and shop donations are live )"
R"(from the order ledger</p>)");
// The methodology prose, in the legal pages' section shape and CSS.
std::vector<SafeHtml> sections;
for (const LegalSection& sec : notes.sections) {
std::vector<SafeHtml> paras;
for (const std::string& para : sec.body) {
paras.push_back(Format(R"(<p>{}</p>)", Autolink(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 = notes.title + " — Catcrafts";
page.meta.description = notes.lede;
page.meta.canonical = "/financials";
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="page-header__lede">{}</p>)"
R"({})"
R"(</header>)"
R"(<div class="fin"{}{}{}{}{}{}>)"
R"(<section class="section"><h2 class="section__title">Income</h2>)"
R"(<table class="spec-table"><tbody>{}</tbody></table></section>)"
R"(<section class="section"><h2 class="section__title">Expenses</h2>{}</section>)"
R"({})"
R"(</div>)"
R"(<div class="legal">{}</div>)",
Escape(notes.title), Escape(notes.lede), freshness,
Attr("data-fin-sales-count", std::to_string(salesCount)),
Attr("data-fin-sales-minor", std::to_string(salesTotalMinor)),
showDonations ? Attr("data-fin-donations-count", std::to_string(donationCount))
: SafeHtml{},
showDonations ? Attr("data-fin-donations-minor", std::to_string(donationsMinor))
: SafeHtml{},
fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor()))
: SafeHtml{},
fin.Loaded() ? Attr("data-fin-net-minor", std::to_string(netMinor)) : SafeHtml{},
Join(incomeRows), expenses, netBlock, 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;
}
// Only posts that have a page. A post with no body has a slug but nothing
// to show, so finding it here would mint an indexable URL for a title and
// a link — see Post::HasPage.
const Post* FindPost(std::string_view slug) const {
for (const Post& p : posts) {
if (p.HasPage() && 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::Post: {
// A slug that parsed but names no post is a 404, for the same
// reason an unknown product slug is: otherwise every typo and
// every retired post becomes an indexable empty page.
if (const Post* p = content.FindPost(route.slug)) return RenderPost(*p);
break;
}
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::Financials: {
// The live totals are server state, and the server intercepts this
// route before shared dispatch, exactly like orders. Reaching this
// case means the wasm app is rendering with the backend down — an
// honest notice beats a page of zeros posing as the company's
// finances.
RenderedPage page;
page.meta.title = "Financials — Catcrafts";
page.meta.canonical = "/financials";
page.meta.refreshSeconds = 30;
page.main = Raw(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">Financials unavailable</h1>)"
R"(<p class="page-header__lede">The live figures aren't reachable )"
R"(right now. This page retries automatically.</p>)"
R"(</header>)");
return page;
}
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\","
// No America/* zones: USD and CAD left AllCurrencies with the sale itself,
// so a visitor there reads the plain euro export price like anywhere the
// shop has no local currency for.
"\"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, boxed unit weight, the carrier's per-country
// weight-bracket ladder) and mirrors ComputeTotals exactly: line total,
// floor((x*10000+6050)/12100) for the export net, and shipping from the
// cheapest bracket that carries qty × weight — the same rule Money::RateFor
// applies server-side. Same integers, same formula, so this preview and the
// charged amount cannot disagree.
//
// It also has to REFUSE where checkout refuses, which since the zone
// fallback went away is a real case rather than a theoretical one: no
// ladder for the country, or no bracket heavy enough for the quantity. The
// messages are the server's own templates, filled here.
"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}"
// Refused destination: say so where the total would have been, instead of
// pricing an order the server will decline. The same two gates as
// ValidateCheckout, in the same order, from the same sentences — sanctions
// first, then absence from the shipping list.
"var eu=ecc.indexOf(k)>-1;"
"if(d.s&&d.s.indexOf(k)>-1){if(out){out.textContent=d.sm;out.hidden=false}return}"
"if(d.w&&d.w.indexOf(k)<0){if(out){out.textContent=d.rm;out.hidden=false}return}"
"var line=unit*qty;"
"var goods=eu?line:Math.floor((line*10000+6050)/12100);"
// No ladder for this destination: there is no price, and saying so beats
// quoting a total the submit would then reject.
"var lad=d.c&&d.c[k];"
"var say=function(m){if(out){out.textContent=m;out.hidden=false}};"
"if(!lad){say(d.nm.split(\"{cc}\").join(k));return}"
// Cheapest bracket that carries the whole order, and the heaviest bracket
// there is — the second one turns into \"up to N per order\" when nothing
// carries this many.
"var g=qty*d.g,ship=0,top=0;"
"for(var i=0;i<lad.length;i++){"
"if(lad[i][0]>=g&&(ship===0||lad[i][1]<ship))ship=lad[i][1];"
"if(lad[i][0]>top)top=lad[i][0];"
"}"
"if(!ship){"
"var fits=d.g>0?Math.floor(top/d.g):0;"
"say(fits>0?d.hm.split(\"{cc}\").join(k).split(\"{n}\").join(fits)"
":d.hm0.split(\"{cc}\").join(k));return"
"}"
"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"
// Declaring this is what stops Safari probing the root for
// /apple-touch-icon-precomposed.png and /apple-touch-icon.png, which is
// its fallback when a page names no Home Screen icon. /favicon.ico
// deliberately gets NO <link>: it exists purely so the by-path request
// resolves for clients that ignore the SVG, and declaring it would give
// SVG-capable browsers a reason to fetch the raster as well.
"<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\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