about page
All checks were successful
Deploy / build-deploy (push) Successful in 4m46s

This commit is contained in:
Jorijn van der Graaf 2026-08-05 07:05:00 +02:00
commit cc02023625
10 changed files with 341 additions and 39 deletions

View file

@ -154,6 +154,45 @@ export const std::vector<Demo>& Demos() {
return demos;
}
// The about page: the deliberate weld between the person and the company.
// Search and AI answer engines had already connected Jorijn to catcrafts.net
// through the kernel patch trail, but with nothing first-party to quote they
// guessed — one AI overview attributed the domain to a different developer
// entirely, several redirected visitors to the Minecraft server on the
// singular domain. This page is the authoritative answer to "who is behind
// Catcrafts", for people and for machines. Same headed-sections shape as the
// legal pages, so it reuses their renderer's markup and CSS.
export const LegalPage& AboutPage() {
static const LegalPage page{
.slug = "about",
.title = "About",
.updated = "2026-08-05",
.lede = "Catcrafts is a one-person company. This page is about the person, the mission, and the name.",
.sections = {
{ "Who",
{
"Catcrafts is the registered one-person company (KVK 78437059) of Jorijn van der Graaf, a Dutch software engineer better known in the Linux mobile world as TheMightyCat.",
} },
{ "The mission",
{
"Accessible open-source software and hardware, for the benefit of everyone. Software you can read, hardware you can repair, and phones that answer to their owner rather than to an advertising department. A real alternative to big tech not a manifesto about one, but things you can download, flash and buy today.",
"Everything Catcrafts makes is published as open source. The shop is how the development gets funded: buying a phone here pays for the stack it runs.",
} },
{ "The work",
{
"imsd is the IMS/VoLTE implementation that makes phone calls work on mainline-Linux phones — written from scratch and tested on a live Dutch network. It is the reason a Fairphone 6 running postmarketOS can ring. Jorijn maintains the Fairphone 6 port of postmarketOS and upstreams the hardware support into the Linux kernel: sensor bindings, Qualcomm audio, the unglamorous patches that make a phone a phone.",
"The Crafter suite is the other half: a C++ build system with no DSL, a graphics engine that ray-traces natively on Vulkan and in the browser through WebGPU, a QUIC and HTTP/3 networking stack, and more. This website is built with all of it — the same C++ renders each page on the server and again in your browser as WebAssembly.",
"And ofcourse all the upstreaming work that comes along, Catcrafts will upstream everything it makes whenever possible.",
} },
{ "The name",
{
"Catcrafts is not the Minecraft server. That is catcraft.net — singular — a domain Jorijn registered in 2019 and let lapse, on which a rather successful Minecraft community then grew. He has been explaining the difference ever since, mostly to search engines. The cat in the logo speaks shell, not creeper.",
} },
},
};
return page;
}
export const std::vector<LegalPage>& LegalPages() {
static const std::vector<LegalPage> pages = {
{
@ -210,7 +249,7 @@ export const std::vector<LegalPage>& LegalPages() {
} },
{ "Business details",
{
"Catcrafts, KVK 78437059, VAT NL003329281B38.",
"Catcrafts, owner Jorijn van der Graaf, KVK 78437059, VAT NL003329281B38.",
} },
{ "Security reports",
{

View file

@ -108,6 +108,13 @@ export struct PageMeta {
// prices are always in the markup, the HTML is identical for every
// visitor, and nothing is detected server-side.
bool geoPriceHint = false;
// schema.org JSON-LD for this page, already serialized. Emitted verbatim
// inside <script type="application/ld+json"> — inert data, not code, so
// it does not count against the shop pages' one-executable-script rule.
// Set by RenderHome (Organization: who Catcrafts is, versus the two
// name-twins) and RenderProduct (Product: what is sold, at which prices —
// the same integers the checkout charges).
std::string jsonLd;
};
export struct Spec {

View file

@ -20,6 +20,7 @@ namespace Catcrafts {
export enum class RouteKind {
Home,
About, // /about — the person behind the company
Projects,
Posts,
Demos, // /demos — the list
@ -92,6 +93,7 @@ export Route ParseRoute(std::string_view path, std::string_view query = {}) {
r.query = std::string(query);
if (p == "/") { r.kind = RouteKind::Home; return r; }
if (p == "/about") { r.kind = RouteKind::About; return r; }
if (p == "/projects") { r.kind = RouteKind::Projects; return r; }
if (p == "/posts") { r.kind = RouteKind::Posts; return r; }
if (p == "/demos") { r.kind = RouteKind::Demos; return r; }
@ -182,12 +184,13 @@ export struct NavItem {
};
export std::span<const NavItem> NavItems() {
static constexpr std::array<NavItem, 5> items{{
static constexpr std::array<NavItem, 6> items{{
{ "Home", "/", RouteKind::Home },
{ "Shop", "/shop", RouteKind::Shop },
{ "Projects", "/projects", RouteKind::Projects },
{ "Posts", "/posts", RouteKind::Posts },
{ "Demos", "/demos", RouteKind::Demos },
{ "About", "/about", RouteKind::About },
}};
return items;
}
@ -199,8 +202,8 @@ export std::span<const std::string_view> SitemapPaths() {
// /shop/<slug> entries are appended by the caller from the loaded product
// list — the sitemap has to reflect what actually exists, and a hardcoded
// slug list here would be one more thing to forget to update.
static constexpr std::array<std::string_view, 8> paths{
"/", "/shop", "/projects", "/posts", "/demos",
static constexpr std::array<std::string_view, 9> paths{
"/", "/about", "/shop", "/projects", "/posts", "/demos",
// Legal pages are indexable on purpose: they are trust signals, and a
// buyer looking for the returns policy before purchasing should be able
// to find it from a search engine.

View file

@ -20,6 +20,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
export module Catcrafts.Shared:Views;
import std;
import :Content;
import :Html;
import :Form;
import :Model;
@ -127,6 +128,40 @@ export SafeHtml RenderFooter() {
Url("href", "/legal/imprint"));
}
// ── schema.org JSON-LD ────────────────────────────────────────────────
//
// Machine-readable facts for crawlers and AI answer engines. This exists
// because two name-twins (a Minecraft server on the singular domain, a cat
// conservation program) kept absorbing the brand in search results and AI
// overviews — one overview flatly asserted that nobody named Catcrafts sells
// Fairphone hardware. Prose can be paraphrased away; typed identity and
// offer data are what those systems quote.
// JSON string escaping. Its own function rather than the HTML escaper
// because the rules are different (backslash and quote, not ampersand).
std::string JsonStr(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
out += '"';
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<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,
@ -166,6 +201,10 @@ export RenderedPage RenderHome(std::span<const Project> projects,
R"(<a class="btn btn--primary"{}>Browse projects</a>)"
R"(<a class="btn"{}>Browse shop</a>)"
R"(</p>)"
// The person-company weld, human-readable edition. The about page
// and the Organization founder record are the machine-readable one.
R"(<p class="hero__byline">Catcrafts is the one-person company of )"
R"(<a{}>Jorijn van der Graaf</a>.</p>)"
R"(</section>)"
R"(<section class="section">)"
R"(<h2 class="section__title">Featured work</h2>)"
@ -174,6 +213,7 @@ export RenderedPage RenderHome(std::span<const Project> projects,
R"({})",
Url("href", "/projects"),
Url("href", "/shop"),
Url("href", "/about"),
cards.empty() ? Raw(R"(<p class="empty">No featured projects yet.</p>)") : Join(cards),
recent.empty() ? SafeHtml{} : Format(
R"(<section class="section">)"
@ -190,6 +230,20 @@ export RenderedPage RenderHome(std::span<const Project> projects,
"alternative to big tech. A C++23 engine, build system and networking "
"stack from scratch, and Linux with IMS/VoLTE on the Fairphone 6.";
page.meta.canonical = "/";
// The identity record: name, registered VAT identity and the Forgejo
// profile separate this Catcrafts from the name-twins by type and by
// registration, not just by prose. All literal — nothing user-supplied.
page.meta.jsonLd =
R"({"@context":"https://schema.org","@type":"Organization",)"
R"("name":"Catcrafts","url":"https://catcrafts.net/",)"
R"("logo":"https://catcrafts.net/favicon.svg",)"
R"("email":"info@catcrafts.net","vatID":"NL003329281B38",)"
R"("founder":{"@type":"Person","name":"Jorijn van der Graaf",)"
R"("url":"https://catcrafts.net/about"},)"
R"("description":"Catcrafts makes accessible, open-source software and hardware: )"
R"(the Crafter C++23 suite, the imsd IMS/VoLTE implementation, and Fairphone 6 )"
R"(handsets sold with postmarketOS preinstalled.",)"
R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]})";
page.main = std::move(main);
return page;
}
@ -635,8 +689,48 @@ export RenderedPage RenderProduct(const Product& product,
page.meta.description = product.tagline;
page.meta.canonical = "/shop/" + product.slug;
page.meta.ogType = "product";
page.meta.ogImage = product.image;
page.meta.geoPriceHint = true;
// The commercial record: one Offer per variant, prices from the same
// integers the checkout charges — this markup can never advertise a
// number the shop doesn't honour. Availability tracks the status field,
// so launch day flips PreOrder to InStock with no edit here.
{
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
std::string_view availability =
product.Buyable() ? "https://schema.org/InStock"
: product.ComingSoon() ? "https://schema.org/PreOrder"
: "https://schema.org/OutOfStock";
std::string offers;
for (const Variant& v : product.variants) {
if (!offers.empty()) offers += ',';
offers += std::format(
R"({{"@type":"Offer","name":{},"price":{},"priceCurrency":"EUR",)"
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})",
JsonStr(v.label), JsonStr(Money::FormatMinor(v.priceInclMinor)),
availability, JsonStr(productUrl));
}
// A variantless product still gets its one offer from the base price.
if (offers.empty() && product.priceInclMinor > 0) {
offers += std::format(
R"({{"@type":"Offer","price":{},"priceCurrency":"EUR",)"
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})",
JsonStr(Money::FormatMinor(product.priceInclMinor)),
availability, JsonStr(productUrl));
}
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"Product",)"
R"("name":{},"description":{},"image":{},"url":{},"offers":[{}]}})",
JsonStr(product.name), JsonStr(product.tagline),
JsonStr(product.image.empty()
? std::string{}
: "https://catcrafts.net" + product.image),
JsonStr(productUrl), offers);
}
SafeHtml media = product.image.empty() ? SafeHtml{} : Format(
R"(<img class="product__photo" decoding="async" alt="{}"{}>)",
Escape(product.name), Url("src", product.image));
@ -862,6 +956,47 @@ export RenderedPage RenderLegal(const LegalPage& lp) {
return page;
}
// ── about ─────────────────────────────────────────────────────────────
// The person behind the company, in the same headed-sections shape (and CSS)
// as the legal pages. Carries the ProfilePage/Person JSON-LD that formally
// joins "Jorijn van der Graaf" to this domain — the entity link search and
// answer engines were previously left to guess at, sometimes wrongly.
export RenderedPage RenderAbout(const LegalPage& about) {
std::vector<SafeHtml> sections;
for (const LegalSection& sec : about.sections) {
std::vector<SafeHtml> paras;
for (const std::string& para : sec.body) {
paras.push_back(Format(R"(<p>{}</p>)", Escape(para)));
}
sections.push_back(Format(
R"(<section class="legal__section">)"
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
Escape(sec.heading), Join(paras)));
}
RenderedPage page;
page.meta.title = "About — Catcrafts";
page.meta.description = about.lede;
page.meta.canonical = "/about";
page.meta.jsonLd =
R"({"@context":"https://schema.org","@type":"ProfilePage","mainEntity":{)"
R"("@type":"Person","name":"Jorijn van der Graaf","alternateName":"TheMightyCat",)"
R"("url":"https://catcrafts.net/about","nationality":"NL",)"
R"("worksFor":{"@type":"Organization","name":"Catcrafts","url":"https://catcrafts.net/"},)"
R"("sameAs":["https://invent.kde.org/themightycat",)"
R"("https://forgejo.catcrafts.net/Catcrafts/"]}})";
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="page-header__lede">{}</p>)"
R"(</header>)"
R"(<div class="legal">{}</div>)",
Escape(about.title), Escape(about.lede),
Join(sections));
return page;
}
// ── demos ─────────────────────────────────────────────────────────────
export RenderedPage RenderDemos(std::span<const Demo> demos) {
@ -1002,6 +1137,7 @@ export RenderedPage RenderRoute(const Route& route, const SiteContent& content)
RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) {
switch (route.kind) {
case RouteKind::Home: return RenderHome(content.projects, content.posts);
case RouteKind::About: return RenderAbout(Content::AboutPage());
case RouteKind::Projects: return RenderProjects(content.projects);
case RouteKind::Posts: return RenderPosts(content.posts);
case RouteKind::Demos: return RenderDemos(content.demos);
@ -1212,6 +1348,37 @@ export std::string RenderDocument(const RenderedPage& page,
: 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"
@ -1258,7 +1425,7 @@ export std::string RenderDocument(const RenderedPage& page,
page.meta.refreshSeconds)
: std::string{},
page.meta.geoPriceHint ? kGeoPriceHintScript : std::string_view{},
canonical.empty() ? std::string{} : canonical + "\n",
(canonical.empty() ? std::string{} : canonical + "\n") + headExtras,
cssHref,
bootScripts,
nav.Str(),