SEO
Some checks failed
Deploy / build-deploy (push) Failing after 7m0s

This commit is contained in:
Jorijn van der Graaf 2026-08-06 23:42:25 +02:00
commit fca089c20d
5 changed files with 298 additions and 70 deletions

View file

@ -151,6 +151,11 @@ std::string JsonStr(std::string_view s) {
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));
@ -233,20 +238,56 @@ export RenderedPage RenderHome(std::span<const Project> projects,
"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 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.
// 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","@type":"Organization",)"
R"("name":"Catcrafts","url":"https://catcrafts.net/",)"
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"("founder":{"@type":"Person","name":"Jorijn van der Graaf",)"
R"("url":"https://catcrafts.net/about"},)"
R"("identifier":[)"
R"({"@type":"PropertyValue","propertyID":"KVK","value":"78437059"},)"
R"({"@type":"PropertyValue","propertyID":"VAT","value":"NL003329281B38"},)"
R"({"@type":"PropertyValue","propertyID":"EORI","value":"NL1900095326"}],)"
// The founder is a reference, exactly like the org: the Person node's
// full definition lives on /about under this @id, so both pages talk
// about ONE person — the same join, for the same reason.
R"("founder":{"@type":"Person","@id":"https://catcrafts.net/about#person",)"
R"("name":"Jorijn van der Graaf","url":"https://catcrafts.net/about"},)"
R"("description":"Catcrafts makes accessible, open-source software and hardware: )"
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and Fairphone 6 )"
R"(handsets sold with postmarketOS preinstalled.",)"
R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]})";
R"("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;
}
@ -481,6 +522,27 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
"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>)"
@ -695,17 +757,22 @@ export RenderedPage RenderProduct(const Product& 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.
// The commercial record: a ProductGroup with one variant Product per
// colour, each carrying its ONE offer, prices from the same integers the
// checkout charges — this markup can never advertise a number the shop
// doesn't honour. A group rather than one Product with three priced
// offers, because multiple offers on a product read as multiple sellers
// of the same thing, and which price gets quoted is then the consumer's
// guess; as variants, each colour owns its price. Availability tracks
// the status field, so launch day flips PreOrder to InStock with no edit
// here.
//
// Merchant-grade: each offer also carries shippingDetails and a return
// policy, which is what Google Merchant Center's website-crawl feed needs
// to list the product without a CSV in sight. Shipping uses the STATIC
// zone rates on purpose: the checkout charges live carrier rates, which
// run at or below the zone fallbacks — a listing may overstate shipping,
// never understate it.
// to list the product without a CSV in sight — productGroupID is what it
// maps to item_group_id. Shipping uses the STATIC zone rates on purpose:
// the checkout charges live carrier rates, which run at or below the
// zone fallbacks — a listing may overstate shipping, never understate it.
{
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
std::string_view availability =
@ -765,39 +832,68 @@ export RenderedPage RenderProduct(const Product& product,
const std::string offerTail = std::format(
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}},)"
// Same @id as the home page's Organization: the seller of these
// offers is the KVK-registered company, and joining the nodes is
// what carries that registration onto the offer instead of
// leaving a bare name a consumer has to resolve by string match.
R"("url":{},"seller":{{"@id":"https://catcrafts.net/#organization",)"
R"("@type":"Organization","name":"Catcrafts"}},)"
R"("shippingDetails":{},"hasMerchantReturnPolicy":{}}})",
availability, JsonStr(productUrl), shippingDetails, returnPolicy);
std::string offers;
for (const Variant& v : product.variants) {
if (!offers.empty()) offers += ',';
offers += std::format(
R"({{"@type":"Offer","name":{},"sku":{},"price":{},"priceCurrency":"EUR",)",
JsonStr(v.label), JsonStr(product.slug + "-" + v.slug),
JsonStr(Money::FormatMinor(v.priceInclMinor)));
offers += offerTail;
}
// A variantless product still gets its one offer from the base price.
if (offers.empty() && product.priceInclMinor > 0) {
offers += std::format(
R"({{"@type":"Offer","sku":{},"price":{},"priceCurrency":"EUR",)",
JsonStr(product.slug),
JsonStr(Money::FormatMinor(product.priceInclMinor)));
offers += offerTail;
}
const std::string brand = product.brand.empty()
? std::string{}
: std::format(R"("brand":{{"@type":"Brand","name":{}}},)",
JsonStr(product.brand));
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"Product",)"
R"("name":{},{}"description":{},"image":{},"url":{},"offers":[{}]}})",
JsonStr(product.name), brand, JsonStr(product.tagline),
JsonStr(product.image.empty()
? std::string{}
: "https://catcrafts.net" + product.image),
JsonStr(productUrl), offers);
// 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(
@ -1048,13 +1144,40 @@ export RenderedPage RenderAbout(const LegalPage& about) {
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/"},)"
// 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/Catcrafts/"]}})";
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>)"