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

@ -37,7 +37,7 @@ jobs:
nodejs \ nodejs \
clang lld libc++ \ clang lld libc++ \
wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \ wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \
git curl tar rsync zstd gzip jq openssl ffmpeg gnupg \ git curl tar rsync zstd gzip jq openssl ffmpeg gnupg llvm \
cmake make perl cmake make perl
# cmake/make/perl: the native server product pulls Crafter.Network, # cmake/make/perl: the native server product pulls Crafter.Network,
# which builds msquic (ExternalBuilder::CMake) with quictls — CMake's # which builds msquic (ExternalBuilder::CMake) with quictls — CMake's

View file

@ -8,9 +8,11 @@
"excerpt": "While celebrating my previous post of working call audio @pvagner@fedi.ml left this excellent comment: And this reminded me something that i and many devs sadly often forget, if its works for me it doesn't mean it works for everyone. My philosophy is that linux should be as…", "excerpt": "While celebrating my previous post of working call audio @pvagner@fedi.ml left this excellent comment: And this reminded me something that i and many devs sadly often forget, if its works for me it doesn't mean it works for everyone. My philosophy is that linux should be as…",
"media": [ "media": [
{ {
"src": "https://files.catbox.moe/q0acuo.mp4", "src": "/media/54d923869e19d71c.mp4",
"kind": "video", "kind": "video",
"poster": "https://ani.social/pictrs/image/5948f014-e9d4-4ace-bf6f-6f85e9b5f2fc.webp" "poster": "/media/63acea1959c6400d.webp",
"w": 1080,
"h": 1080
} }
], ],
"score": 82, "score": 82,
@ -25,9 +27,11 @@
"excerpt": "At first, man thinks he controls the modem. But after the 20th reboot, man learns the modem controls him. — Epictetus So here it is! working VoLTE calls with audio on linux! What a rollercoaster it has been i'll tell you that. The frequency between my previous posts was only a…", "excerpt": "At first, man thinks he controls the modem. But after the 20th reboot, man learns the modem controls him. — Epictetus So here it is! working VoLTE calls with audio on linux! What a rollercoaster it has been i'll tell you that. The frequency between my previous posts was only a…",
"media": [ "media": [
{ {
"src": "https://ani.social/pictrs/image/50e19f20-3667-4368-91ec-d787c515b9e7.mp4", "src": "/media/8bc5b5a6565ba864.mp4",
"kind": "video", "kind": "video",
"poster": "https://ani.social/pictrs/image/00dfc344-88e7-46a1-973b-db7f3e58f679.webp" "poster": "/media/7133dc70534bf47a.webp",
"w": 854,
"h": 480
} }
], ],
"score": 501, "score": 501,
@ -42,9 +46,11 @@
"excerpt": "My quest to 100% feature completeness continues! Today i bring before you... The working microphone! Now everyone is going to ask: Does this mean working calls? I asked the same thing, and with maybe a bit too much excitement i inserted my SIM card. But the answer is no, neither…", "excerpt": "My quest to 100% feature completeness continues! Today i bring before you... The working microphone! Now everyone is going to ask: Does this mean working calls? I asked the same thing, and with maybe a bit too much excitement i inserted my SIM card. But the answer is no, neither…",
"media": [ "media": [
{ {
"src": "https://ani.social/pictrs/image/752d0c86-666d-48dc-a0c5-aa2bba430afd.mp4", "src": "/media/8e0ef8ba7e9769ec.mp4",
"kind": "video", "kind": "video",
"poster": "https://ani.social/pictrs/image/37bcfe3c-0c6b-4250-b432-7a71cf5de927.webp" "poster": "/media/7f1d353c81fa55e6.webp",
"w": 854,
"h": 480
} }
], ],
"score": 296, "score": 296,
@ -59,9 +65,11 @@
"excerpt": "Well that was easy. Here i was thinking audio was going to be the boss fight... These were small easily identifiable kernel changes, not the entire investigations that GPS and NFC needed. Ill be working to getting calls fully working, but this is already a big step. And speaking…", "excerpt": "Well that was easy. Here i was thinking audio was going to be the boss fight... These were small easily identifiable kernel changes, not the entire investigations that GPS and NFC needed. Ill be working to getting calls fully working, but this is already a big step. And speaking…",
"media": [ "media": [
{ {
"src": "https://ani.social/pictrs/image/5963d0aa-5bff-4d46-bb1c-d3e1f6563918.mp4", "src": "/media/f9d29d90b4c9c9b9.mp4",
"kind": "video", "kind": "video",
"poster": "https://ani.social/pictrs/image/25cca4db-40ab-4664-8a1d-6d93e3ffa249.webp" "poster": "/media/07b79c6415f4c487.webp",
"w": 854,
"h": 480
} }
], ],
"score": 165, "score": 165,
@ -76,9 +84,11 @@
"excerpt": "No new features today, just wanted to make a short post. It is completely possible to do linux development on the go! Is it convenient? Probably not. Is a laptop better? Most likely. Is it funny? Yes. Also a double table is definitely better: photo of a linux phone with a…", "excerpt": "No new features today, just wanted to make a short post. It is completely possible to do linux development on the go! Is it convenient? Probably not. Is a laptop better? Most likely. Is it funny? Yes. Also a double table is definitely better: photo of a linux phone with a…",
"media": [ "media": [
{ {
"src": "https://ani.social/pictrs/image/a318590f-6ab0-48ac-8a1d-81c37cda77f0.webp", "src": "/media/1c75b19e98c7b6ef.webp",
"kind": "image", "kind": "image",
"poster": "" "poster": "",
"w": 1920,
"h": 887
} }
], ],
"score": 264, "score": 264,
@ -93,9 +103,11 @@
"excerpt": "This is the continuation of my ongoing effort to get the FP6 fully feature complete with linux. I chose NFC cause i thought it would be relatively easy like GPS, well that assumption turned out to be wrong. But alot of time later and as you can see in the video now reading…", "excerpt": "This is the continuation of my ongoing effort to get the FP6 fully feature complete with linux. I chose NFC cause i thought it would be relatively easy like GPS, well that assumption turned out to be wrong. But alot of time later and as you can see in the video now reading…",
"media": [ "media": [
{ {
"src": "https://ani.social/pictrs/image/fae177a4-73d2-43a7-9dd6-0228af1424f8.mp4", "src": "/media/c8de4451ce9d5663.mp4",
"kind": "video", "kind": "video",
"poster": "https://ani.social/pictrs/image/883180d4-2994-44c9-b2a9-9ba5400b3e59.webp" "poster": "/media/c6a1a1a41b2883e5.webp",
"w": 854,
"h": 480
} }
], ],
"score": 218, "score": 218,
@ -110,9 +122,11 @@
"excerpt": "I really want to turn this phone into my daily driver, so i started working on one of the features i would want in a daily driver, working GPS. This is the current component support table of postmarketos: FP6 feature support table i think audio is too complex for me, so i…", "excerpt": "I really want to turn this phone into my daily driver, so i started working on one of the features i would want in a daily driver, working GPS. This is the current component support table of postmarketos: FP6 feature support table i think audio is too complex for me, so i…",
"media": [ "media": [
{ {
"src": "https://ani.social/pictrs/image/c2561f4d-73ce-4e89-89bd-5ada7990ded9.webp", "src": "/media/ce6475f10b3baeee.webp",
"kind": "image", "kind": "image",
"poster": "" "poster": "",
"w": 887,
"h": 1920
} }
], ],
"score": 471, "score": 471,

View file

@ -1,9 +1,7 @@
User-agent: * User-agent: *
Allow: / Allow: /
# The landing page for a submitted reservation. It also carries # Order pages live at unguessable capability URLs and carry X-Robots-Tag:
# X-Robots-Tag: noindex — this is the belt to that braces, since a crawler that # noindex themselves; there is no path prefix worth disallowing here.
# never fetches the URL cannot index it by accident.
Disallow: /reserved
Sitemap: https://catcrafts.net/sitemap.xml Sitemap: https://catcrafts.net/sitemap.xml

View file

@ -421,9 +421,29 @@ void RunMoneySelfTest() {
"content: emergency-calling safety warning present and honest"); "content: emergency-calling safety warning present and honest");
Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100, Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100,
"content: warranty is written, not a placeholder"); "content: warranty is written, not a placeholder");
// The product page's schema.org record must parse with our own
// JSON parser and carry one offer per colour — the offers are
// built from the same integers the checkout charges.
{
auto pp = Views::RenderProduct(pr, Rates{});
auto ld = Json::Parse(pp.meta.jsonLd);
bool offersOk = false;
if (ld && ld->IsObject()) {
if (const Json::Value* o = ld->Find("offers"); o && o->IsArray()) {
offersOk = o->array.size() == pr.variants.size();
}
}
Check(ld && ld->IsObject() && ld->Str("@type") == "Product" && offersOk,
"schema: product JSON-LD parses, one offer per variant");
}
} }
Check(!Content::Projects().empty(), "content: projects present"); Check(!Content::Projects().empty(), "content: projects present");
Check(Content::LegalPages().size() == 3, "content: three legal pages"); Check(Content::LegalPages().size() == 3, "content: three legal pages");
Check(Content::AboutPage().sections.size() >= 3
&& !Content::AboutPage().sections[0].body.empty()
&& Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf")
!= std::string::npos,
"content: about page names the founder");
Check(!Content::Demos().empty(), "content: demos present"); Check(!Content::Demos().empty(), "content: demos present");
} }
@ -691,7 +711,7 @@ int main(int argc, char** argv) {
// --routes: status + title for every route, for a quick smoke check. // --routes: status + title for every route, for a quick smoke check.
if (has("--routes")) { if (has("--routes")) {
const Views::SiteContent content = LoadContent("content"); const Views::SiteContent content = LoadContent("content");
for (std::string_view p : { "/", "/shop", "/shop/fp6-pmos", "/shop/nope", for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
"/order/0123456789abcdef0123456789abcdef", "/order/0123456789abcdef0123456789abcdef",
"/order/not-a-token", "/order/not-a-token",
"/legal/privacy", "/legal/imprint", "/legal/privacy", "/legal/imprint",

View file

@ -154,6 +154,45 @@ export const std::vector<Demo>& Demos() {
return 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() { export const std::vector<LegalPage>& LegalPages() {
static const std::vector<LegalPage> pages = { static const std::vector<LegalPage> pages = {
{ {
@ -210,7 +249,7 @@ export const std::vector<LegalPage>& LegalPages() {
} }, } },
{ "Business details", { "Business details",
{ {
"Catcrafts, KVK 78437059, VAT NL003329281B38.", "Catcrafts, owner Jorijn van der Graaf, KVK 78437059, VAT NL003329281B38.",
} }, } },
{ "Security reports", { "Security reports",
{ {

View file

@ -108,6 +108,13 @@ export struct PageMeta {
// prices are always in the markup, the HTML is identical for every // prices are always in the markup, the HTML is identical for every
// visitor, and nothing is detected server-side. // visitor, and nothing is detected server-side.
bool geoPriceHint = false; 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 { export struct Spec {

View file

@ -20,6 +20,7 @@ namespace Catcrafts {
export enum class RouteKind { export enum class RouteKind {
Home, Home,
About, // /about — the person behind the company
Projects, Projects,
Posts, Posts,
Demos, // /demos — the list Demos, // /demos — the list
@ -92,6 +93,7 @@ export Route ParseRoute(std::string_view path, std::string_view query = {}) {
r.query = std::string(query); r.query = std::string(query);
if (p == "/") { r.kind = RouteKind::Home; return r; } 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 == "/projects") { r.kind = RouteKind::Projects; return r; }
if (p == "/posts") { r.kind = RouteKind::Posts; return r; } if (p == "/posts") { r.kind = RouteKind::Posts; return r; }
if (p == "/demos") { r.kind = RouteKind::Demos; return r; } if (p == "/demos") { r.kind = RouteKind::Demos; return r; }
@ -182,12 +184,13 @@ export struct NavItem {
}; };
export std::span<const NavItem> NavItems() { export std::span<const NavItem> NavItems() {
static constexpr std::array<NavItem, 5> items{{ static constexpr std::array<NavItem, 6> items{{
{ "Home", "/", RouteKind::Home }, { "Home", "/", RouteKind::Home },
{ "Shop", "/shop", RouteKind::Shop }, { "Shop", "/shop", RouteKind::Shop },
{ "Projects", "/projects", RouteKind::Projects }, { "Projects", "/projects", RouteKind::Projects },
{ "Posts", "/posts", RouteKind::Posts }, { "Posts", "/posts", RouteKind::Posts },
{ "Demos", "/demos", RouteKind::Demos }, { "Demos", "/demos", RouteKind::Demos },
{ "About", "/about", RouteKind::About },
}}; }};
return items; 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 // /shop/<slug> entries are appended by the caller from the loaded product
// list — the sitemap has to reflect what actually exists, and a hardcoded // list — the sitemap has to reflect what actually exists, and a hardcoded
// slug list here would be one more thing to forget to update. // slug list here would be one more thing to forget to update.
static constexpr std::array<std::string_view, 8> paths{ static constexpr std::array<std::string_view, 9> paths{
"/", "/shop", "/projects", "/posts", "/demos", "/", "/about", "/shop", "/projects", "/posts", "/demos",
// Legal pages are indexable on purpose: they are trust signals, and a // Legal pages are indexable on purpose: they are trust signals, and a
// buyer looking for the returns policy before purchasing should be able // buyer looking for the returns policy before purchasing should be able
// to find it from a search engine. // 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; export module Catcrafts.Shared:Views;
import std; import std;
import :Content;
import :Html; import :Html;
import :Form; import :Form;
import :Model; import :Model;
@ -127,6 +128,40 @@ export SafeHtml RenderFooter() {
Url("href", "/legal/imprint")); 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 ────────────────────────────────────────────────────────────── // ── home ──────────────────────────────────────────────────────────────
export RenderedPage RenderHome(std::span<const Project> projects, 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 btn--primary"{}>Browse projects</a>)"
R"(<a class="btn"{}>Browse shop</a>)" R"(<a class="btn"{}>Browse shop</a>)"
R"(</p>)" 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>)"
R"(<section class="section">)" R"(<section class="section">)"
R"(<h2 class="section__title">Featured work</h2>)" R"(<h2 class="section__title">Featured work</h2>)"
@ -174,6 +213,7 @@ export RenderedPage RenderHome(std::span<const Project> projects,
R"({})", R"({})",
Url("href", "/projects"), Url("href", "/projects"),
Url("href", "/shop"), Url("href", "/shop"),
Url("href", "/about"),
cards.empty() ? Raw(R"(<p class="empty">No featured projects yet.</p>)") : Join(cards), cards.empty() ? Raw(R"(<p class="empty">No featured projects yet.</p>)") : Join(cards),
recent.empty() ? SafeHtml{} : Format( recent.empty() ? SafeHtml{} : Format(
R"(<section class="section">)" 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 " "alternative to big tech. A C++23 engine, build system and networking "
"stack from scratch, and Linux with IMS/VoLTE on the Fairphone 6."; "stack from scratch, and Linux with IMS/VoLTE on the Fairphone 6.";
page.meta.canonical = "/"; 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); page.main = std::move(main);
return page; return page;
} }
@ -635,8 +689,48 @@ export RenderedPage RenderProduct(const Product& product,
page.meta.description = product.tagline; page.meta.description = product.tagline;
page.meta.canonical = "/shop/" + product.slug; page.meta.canonical = "/shop/" + product.slug;
page.meta.ogType = "product"; page.meta.ogType = "product";
page.meta.ogImage = product.image;
page.meta.geoPriceHint = true; 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( SafeHtml media = product.image.empty() ? SafeHtml{} : Format(
R"(<img class="product__photo" decoding="async" alt="{}"{}>)", R"(<img class="product__photo" decoding="async" alt="{}"{}>)",
Escape(product.name), Url("src", product.image)); Escape(product.name), Url("src", product.image));
@ -862,6 +956,47 @@ export RenderedPage RenderLegal(const LegalPage& lp) {
return page; 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 ───────────────────────────────────────────────────────────── // ── demos ─────────────────────────────────────────────────────────────
export RenderedPage RenderDemos(std::span<const Demo> 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) { RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) {
switch (route.kind) { switch (route.kind) {
case RouteKind::Home: return RenderHome(content.projects, content.posts); 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::Projects: return RenderProjects(content.projects);
case RouteKind::Posts: return RenderPosts(content.posts); case RouteKind::Posts: return RenderPosts(content.posts);
case RouteKind::Demos: return RenderDemos(content.demos); case RouteKind::Demos: return RenderDemos(content.demos);
@ -1212,6 +1348,37 @@ export std::string RenderDocument(const RenderedPage& page,
: Format(R"(<link rel="canonical"{}>)", : Format(R"(<link rel="canonical"{}>)",
Url("href", "https://catcrafts.net" + page.meta.canonical)).Str(); 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( return std::format(
"<!doctype html>\n" "<!doctype html>\n"
"<html lang=\"en\">\n<head>\n" "<html lang=\"en\">\n<head>\n"
@ -1258,7 +1425,7 @@ export std::string RenderDocument(const RenderedPage& page,
page.meta.refreshSeconds) page.meta.refreshSeconds)
: std::string{}, : std::string{},
page.meta.geoPriceHint ? kGeoPriceHintScript : std::string_view{}, page.meta.geoPriceHint ? kGeoPriceHintScript : std::string_view{},
canonical.empty() ? std::string{} : canonical + "\n", (canonical.empty() ? std::string{} : canonical + "\n") + headExtras,
cssHref, cssHref,
bootScripts, bootScripts,
nav.Str(), nav.Str(),

View file

@ -738,6 +738,13 @@ treatment it replaces, which read as the wrong category for the work.
border-color: color-mix(in oklab, var(--danger) 40%, transparent); border-color: color-mix(in oklab, var(--danger) 40%, transparent);
background: color-mix(in oklab, var(--danger) 10%, transparent); background: color-mix(in oklab, var(--danger) 10%, transparent);
} }
/* The one-line person-company link under the hero actions. Muted: it is a
fact, not a call to action. */
.hero__byline {
color: var(--text-muted);
font-size: var(--step--1);
}
/* The coming-soon price list: the launch prices per colour, shown where the /* The coming-soon price list: the launch prices per colour, shown where the
colour selector will be once orders open. */ colour selector will be once orders open. */
.checkout__colors { .checkout__colors {

View file

@ -111,7 +111,7 @@ header_has() {
} }
echo "== status codes ==" echo "== status codes =="
for p in / /shop /shop/fp6-pmos /projects /posts /demos /demos/raytracer \ for p in / /about /shop /shop/fp6-pmos /projects /posts /demos /demos/raytracer \
/legal/privacy /legal/terms /legal/imprint /feed.xml /sitemap.xml /api/healthz; do /legal/privacy /legal/terms /legal/imprint /feed.xml /sitemap.xml /api/healthz; do
status "$p" 200 status "$p" 200
done done
@ -145,6 +145,11 @@ else
bad "/demo Location header" "not /demos" bad "/demo Location header" "not /demos"
fi fi
# Open shop or coming-soon? The pricing blob (data-cc) exists only on the real
# order form, so its presence is the probe. Used by the script-shape checks
# below and by the checkout/order/invoice gating further down.
if curl -s "$BASE/shop/fp6-pmos" | grep -q 'data-cc='; then SHOP_OPEN=1; else SHOP_OPEN=0; fi
echo "== the no-JavaScript guarantee ==" echo "== the no-JavaScript guarantee =="
# The site must be complete without the wasm module. If these fail, the SSR # The site must be complete without the wasm module. If these fail, the SSR
# work has regressed and crawlers see an empty page again. # work has regressed and crawlers see an empty page again.
@ -160,16 +165,18 @@ for pg in /legal/privacy /legal/terms /legal/imprint /shop/fp6-pmos; do
done done
# Shop pages are the one exception to script-free: they carry exactly ONE # Shop pages are the one exception to script-free: they carry exactly ONE
# inline script — the timezone price hint. Pin its shape hard: inline only (no # EXECUTABLE inline script — the timezone price hint, whose tag is the bare
# src=, so nothing external can ever ride in under this exception), no network # <script>. JSON-LD blocks (<script type="application/ld+json">) are inert
# APIs, and the page must remain complete without it — both prices in the # data the browser never executes, so they don't count against the rule.
# markup regardless. # Pin the shape hard: inline only (no src=, so nothing external can ever ride
# in under this exception), no network APIs, and the page must remain
# complete without it — both prices in the markup regardless.
for pg in /shop /shop/fp6-pmos; do for pg in /shop /shop/fp6-pmos; do
n=$(curl -s "$BASE$pg" | grep -c '<script' || true) n=$(curl -s "$BASE$pg" | grep -c '<script>' || true)
if [ "$n" = 1 ]; then if [ "$n" = 1 ]; then
ok "$pg carries exactly one script (the price hint)" ok "$pg carries exactly one executable script (the price hint)"
else else
bad "$pg script count" "expected 1, got $n" bad "$pg script count" "expected 1 bare <script>, got $n"
fi fi
if curl -s "$BASE$pg" | grep -qE '<script[^>]*src='; then if curl -s "$BASE$pg" | grep -qE '<script[^>]*src='; then
bad "$pg script" "an external script crept in under the inline exception" bad "$pg script" "an external script crept in under the inline exception"
@ -185,6 +192,46 @@ done
body_has /shop/fp6-pmos 'cc-noneu' "price hint tags the non-EU outcome" body_has /shop/fp6-pmos 'cc-noneu' "price hint tags the non-EU outcome"
body_has /shop/fp6-pmos 'cc-eu' "price hint tags the confirmed-EU outcome too" body_has /shop/fp6-pmos 'cc-eu' "price hint tags the confirmed-EU outcome too"
# ── structured data ──
# The JSON-LD blocks are the machine-readable identity and offer records,
# born from a Google AI overview flatly asserting that nobody named Catcrafts
# sells Fairphone hardware. Both must parse as JSON and carry the facts.
extract_ld() {
curl -s "$BASE$1" \
| grep -o '<script type="application/ld+json">[^<]*' \
| sed 's/^<script type="application\/ld+json">//'
}
if extract_ld / | jq -e '."@type" == "Organization" and .vatID == "NL003329281B38"' >/dev/null 2>&1; then
ok "home Organization schema parses and carries the VAT identity"
else
bad "Organization schema" "missing, unparseable, or wrong identity"
fi
if extract_ld /shop/fp6-pmos | jq -e '."@type" == "Product" and (.offers | length) == 3' >/dev/null 2>&1; then
ok "product schema parses with one offer per colour"
else
bad "Product schema" "missing, unparseable, or wrong offer count"
fi
body_has /shop/fp6-pmos '"price":"563.30"' "schema price is the checkout integer"
if [ "$SHOP_OPEN" = 1 ]; then
body_has /shop/fp6-pmos 'schema.org/InStock' "open shop maps to InStock availability"
else
body_has /shop/fp6-pmos 'schema.org/PreOrder' "coming-soon maps to PreOrder availability"
fi
# og: tags are the link-preview card on Mastodon and Lemmy — where the
# traffic actually comes from.
body_has / 'property="og:title"' "home page has og:title"
body_has /shop/fp6-pmos 'og:image" content="https://catcrafts.net/fp6-pmos.jpg' "product og:image is the absolute photo URL"
# The about page is the person-company weld: the founder must be named in the
# HTML, the Person schema must parse, and the home page byline must link it.
body_has /about 'Jorijn van der Graaf' "about page names the founder"
body_has / 'Jorijn van der Graaf' "home page carries the founder byline"
if extract_ld /about | jq -e '.mainEntity.name == "Jorijn van der Graaf"' >/dev/null 2>&1; then
ok "about Person schema parses and names the founder"
else
bad "Person schema" "missing, unparseable, or wrong name"
fi
# The shop card: one euro number as the crawler/no-JS text, every supported # The shop card: one euro number as the crawler/no-JS text, every supported
# currency pre-formatted server-side as a data attribute for the script to # currency pre-formatted server-side as a data attribute for the script to
# pick from. Converted amounts carry "~". CAD converts the ex-VAT price; # pick from. Converted amounts carry "~". CAD converts the ex-VAT price;
@ -332,6 +379,7 @@ header_has /sitemap.xml 'content-type: *application/xml' "sitemap content-ty
echo "== sitemap and feed content ==" echo "== sitemap and feed content =="
body_has /sitemap.xml "/shop/fp6-pmos" "sitemap lists the product" body_has /sitemap.xml "/shop/fp6-pmos" "sitemap lists the product"
body_has /sitemap.xml "/about" "sitemap lists the about page"
body_has /sitemap.xml "/legal/privacy" "sitemap lists the privacy page" body_has /sitemap.xml "/legal/privacy" "sitemap lists the privacy page"
body_has /sitemap.xml "/demos" "sitemap lists the demos page" body_has /sitemap.xml "/demos" "sitemap lists the demos page"
@ -376,12 +424,11 @@ body_lacks /sitemap.xml "/blog" "sitemap does not advertise the redire
body_lacks /sitemap.xml "/order" "sitemap does not advertise order pages" body_lacks /sitemap.xml "/order" "sitemap does not advertise order pages"
body_has /feed.xml "<feed xmlns=\"http://www.w3.org/2005/Atom\">" "feed is Atom" body_has /feed.xml "<feed xmlns=\"http://www.w3.org/2005/Atom\">" "feed is Atom"
# Open shop or coming-soon? The pricing blob (data-cc) exists only on the real # SHOP_OPEN was probed above (before the script-shape checks). The checkout,
# order form, so its presence is the probe. The checkout, order-lifecycle and # order-lifecycle and invoice suites below only run when the shop is open; the
# invoice suites below only run when the shop is open; the coming-soon branch # coming-soon branch asserts the closed state instead. Launch day (status flip
# asserts the closed state instead. Launch day (status flip to "available" in # to "available" in Catcrafts.Shared-Content.cppm) re-arms the full suite with
# Catcrafts.Shared-Content.cppm) re-arms the full suite with no e2e edit. # no e2e edit.
if curl -s "$BASE/shop/fp6-pmos" | grep -q 'data-cc='; then SHOP_OPEN=1; else SHOP_OPEN=0; fi
echo "== the shop front ==" echo "== the shop front =="
# The price is rendered from the same integers the checkout charges, with the # The price is rendered from the same integers the checkout charges, with the