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

@ -15,8 +15,9 @@
"Match is on the full 'name@instance' form, case-insensitive.",
"",
"Note that `username` is the account the API is queried for, and nothing",
"links to it. The account itself is not advertised anywhere on the site —",
"only the individual posts, each linking to its own thread."
"on the posts page links to it — only the individual posts, each linking",
"to its own thread. The one place the site names the account is the About",
"page's Person JSON-LD, which claims it as a personal profile (sameAs)."
],
"username": "TheMightyCat",

View file

@ -422,25 +422,34 @@ void RunMoneySelfTest() {
Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100,
"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.
// JSON parser and carry one variant Product per colour, each
// with its ONE offer — built from the same integers the
// checkout charges.
{
auto pp = Views::RenderProduct(pr, Rates{});
auto ld = Json::Parse(pp.meta.jsonLd);
bool offersOk = false;
bool variantsOk = false;
if (ld && ld->IsObject()) {
if (const Json::Value* o = ld->Find("offers"); o && o->IsArray()) {
offersOk = o->array.size() == pr.variants.size();
if (const Json::Value* v = ld->Find("hasVariant");
v && v->IsArray() && v->array.size() == pr.variants.size()) {
variantsOk = true;
for (const Json::Value& node : v->array) {
const Json::Value* o = node.Find("offers");
variantsOk = variantsOk && node.Str("@type") == "Product"
&& o && o->IsObject();
}
}
Check(ld && ld->IsObject() && ld->Str("@type") == "Product" && offersOk,
"schema: product JSON-LD parses, one offer per variant");
// Merchant-grade fields: shipping, returns, brand, sku — what
// Merchant Center's website-crawl feed reads at launch.
}
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
"schema: product JSON-LD parses, one variant per colour");
// Merchant-grade fields: shipping, returns, sku, group id —
// what Merchant Center's website-crawl feed reads at launch
// (productGroupID is its item_group_id).
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos,
"schema: offers carry shipping, returns and sku");
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: variants carry shipping, returns, sku and group id");
}
}
Check(!Content::Projects().empty(), "content: projects present");
@ -453,6 +462,86 @@ void RunMoneySelfTest() {
Check(!Content::Demos().empty(), "content: demos present");
}
// ── the identity graph ────────────────────────────────────────────
// "Catcrafts" is two common words with no space, so it competes with a
// decade of kids' craft blogs, Etsy and a Minecraft server on the
// singular domain. The way out is not prose: it is one registered entity
// that every page points at by @id. Those joins are worth asserting
// because breaking one is silent — a typo'd @id still renders, still
// validates as JSON-LD, and still splits the graph back into three
// same-named strangers, which is the exact failure this markup exists to
// prevent.
{
constexpr std::string_view kOrgId = "https://catcrafts.net/#organization";
auto home = Views::RenderHome(Content::Projects(), std::span<const Post>{});
auto ld = Json::Parse(home.meta.jsonLd);
const Json::Value* org = nullptr;
const Json::Value* site = nullptr;
if (ld && ld->IsObject()) {
if (const Json::Value* g = ld->Find("@graph"); g && g->IsArray()) {
for (const Json::Value& node : g->array) {
if (node.Str("@type") == "Organization") org = &node;
if (node.Str("@type") == "WebSite") site = &node;
}
}
}
Check(org && site, "schema: home graph parses, carries Organization and WebSite");
if (org && site) {
Check(org->Str("@id") == kOrgId, "schema: organization node is identified");
Check(site->Str("@id") == "https://catcrafts.net/#website",
"schema: website node is identified");
// The join that makes two nodes one entity rather than two.
const Json::Value* publisher = site->Find("publisher");
Check(publisher && publisher->IsObject() && publisher->Str("@id") == kOrgId,
"schema: website is published by the organization node");
// The navigational query "catcrafts" is answered from the site
// entity, so the spellings people type belong on it.
const Json::Value* alt = site->Find("alternateName");
Check(alt && alt->IsArray() && !alt->array.empty(),
"schema: website carries the spellings people type");
// Registry numbers, typed. These are the part no name-twin can
// produce — each one is checkable against a public register,
// which also means a wrong value is worse than no value.
bool kvk = false, vat = false, eori = false;
if (const Json::Value* ids = org->Find("identifier"); ids && ids->IsArray()) {
for (const Json::Value& id : ids->array) {
if (id.Str("propertyID") == "KVK") kvk = id.Str("value") == "78437059";
if (id.Str("propertyID") == "VAT") vat = id.Str("value") == "NL003329281B38";
if (id.Str("propertyID") == "EORI") eori = id.Str("value") == "NL1900095326";
}
}
Check(kvk && vat && eori, "schema: KVK, VAT and EORI present and exact");
// The name-twin guard. "Cat Crafts" with a space is the generic
// craft phrase owned by everyone else; claiming it as an alternate
// name argues for merging this entity into the corpus it needs to
// stay distinct from.
Check(home.meta.jsonLd.find("Cat Crafts") == std::string::npos,
"schema: the spaced generic is not claimed as a brand name");
}
// Cross-page joins: both must name the SAME @id the home page defines.
auto about = Views::RenderAbout(Content::AboutPage());
Check(about.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: about joins the founder to the organization node");
if (!Content::Products().empty()) {
auto pp = Views::RenderProduct(Content::Products()[0], Rates{});
Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: offers are sold by the organization node");
}
// The person join, same mechanism in the other direction: home's
// founder and about's mainEntity must name one Person node, or "who
// founded Catcrafts" splits into two same-named strangers too.
constexpr std::string_view kPersonId = "https://catcrafts.net/about#person";
Check(home.meta.jsonLd.find(kPersonId) != std::string::npos
&& about.meta.jsonLd.find(kPersonId) != std::string::npos,
"schema: founder and about name one Person node");
}
// ── the Sendcloud response parser ─────────────────────────────────
{
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[

View file

@ -111,9 +111,15 @@ export struct PageMeta {
// 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).
// Set by RenderHome (an @graph of Organization + WebSite: who Catcrafts is
// and what this domain is, versus the two name-twins), RenderAbout
// (ProfilePage: who the founder is), RenderShop (ItemList: which product
// pages exist) and RenderProduct (ProductGroup: what is sold, per colour,
// at which prices — the same integers the checkout charges). About and
// the offers reference the home page's Organization by @id rather than
// restating it — and home's founder references about's Person the same
// way — so every page describes one entity; see the identity graph in
// RenderHome for why that join carries the weight it does.
std::string jsonLd;
};

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));
// 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":{},"image":{},"url":{},"offers":[{}]}})",
JsonStr(product.name), brand, JsonStr(product.tagline),
JsonStr(product.image.empty()
? std::string{}
: "https://catcrafts.net" + product.image),
JsonStr(productUrl), offers);
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>)"

View file

@ -201,15 +201,24 @@ extract_ld() {
| 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
# The home record is an @graph (Organization + WebSite joined by @id); the
# org node inside it must carry the registered identity.
if extract_ld / | jq -e '[."@graph"[]? | select(."@type" == "Organization" and .vatID == "NL003329281B38")] | length == 1' >/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"
# Variants are a ProductGroup: one variant Product per colour, each with its
# own single offer — not one Product with three prices.
if extract_ld /shop/fp6-pmos | jq -e '."@type" == "ProductGroup" and (.hasVariant | length) == 3 and ([.hasVariant[].offers] | length) == 3' >/dev/null 2>&1; then
ok "product schema parses with one variant (and offer) per colour"
else
bad "Product schema" "missing, unparseable, or wrong offer count"
bad "ProductGroup schema" "missing, unparseable, or wrong variant count"
fi
if extract_ld /shop | jq -e '."@type" == "ItemList" and (.itemListElement | length) >= 1' >/dev/null 2>&1; then
ok "shop index carries an ItemList of the product pages"
else
bad "shop ItemList" "missing or unparseable"
fi
body_has /shop/fp6-pmos '"price":"563.30"' "schema price is the checkout integer"
# Merchant-grade offer fields: what Merchant Center's website-crawl feed
@ -219,10 +228,10 @@ body_has /shop/fp6-pmos 'OfferShippingDetails' "offers carry shipping detai
body_has /shop/fp6-pmos 'MerchantReturnPolicy' "offers carry a return policy"
body_has /shop/fp6-pmos '"sku":"fp6-pmos-green"' "offers carry per-variant skus"
body_has /shop/fp6-pmos '"brand":{"@type":"Brand","name":"Fairphone"}' "product carries the hardware brand"
if extract_ld /shop/fp6-pmos | jq -e '.offers[0].shippingDetails | length == 3' >/dev/null 2>&1; then
if extract_ld /shop/fp6-pmos | jq -e '.hasVariant[0].offers.shippingDetails | length == 3' >/dev/null 2>&1; then
ok "shipping details cover all three zones"
else
bad "shipping zones" "expected NL + EU + world tiers in the first offer"
bad "shipping zones" "expected NL + EU + world tiers in the first variant's offer"
fi
if [ "$SHOP_OPEN" = 1 ]; then
body_has /shop/fp6-pmos 'schema.org/InStock' "open shop maps to InStock availability"