parent
f2d20bb409
commit
fca089c20d
5 changed files with 298 additions and 70 deletions
|
|
@ -15,8 +15,9 @@
|
||||||
"Match is on the full 'name@instance' form, case-insensitive.",
|
"Match is on the full 'name@instance' form, case-insensitive.",
|
||||||
"",
|
"",
|
||||||
"Note that `username` is the account the API is queried for, and nothing",
|
"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 —",
|
"on the posts page links to it — only the individual posts, each linking",
|
||||||
"only the individual posts, each linking to its own thread."
|
"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",
|
"username": "TheMightyCat",
|
||||||
|
|
|
||||||
|
|
@ -422,25 +422,34 @@ void RunMoneySelfTest() {
|
||||||
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
|
// The product page's schema.org record must parse with our own
|
||||||
// JSON parser and carry one offer per colour — the offers are
|
// JSON parser and carry one variant Product per colour, each
|
||||||
// built from the same integers the checkout charges.
|
// with its ONE offer — built from the same integers the
|
||||||
|
// checkout charges.
|
||||||
{
|
{
|
||||||
auto pp = Views::RenderProduct(pr, Rates{});
|
auto pp = Views::RenderProduct(pr, Rates{});
|
||||||
auto ld = Json::Parse(pp.meta.jsonLd);
|
auto ld = Json::Parse(pp.meta.jsonLd);
|
||||||
bool offersOk = false;
|
bool variantsOk = false;
|
||||||
if (ld && ld->IsObject()) {
|
if (ld && ld->IsObject()) {
|
||||||
if (const Json::Value* o = ld->Find("offers"); o && o->IsArray()) {
|
if (const Json::Value* v = ld->Find("hasVariant");
|
||||||
offersOk = o->array.size() == pr.variants.size();
|
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");
|
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
|
||||||
// Merchant-grade fields: shipping, returns, brand, sku — what
|
"schema: product JSON-LD parses, one variant per colour");
|
||||||
// Merchant Center's website-crawl feed reads at launch.
|
// 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
|
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
|
||||||
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
|
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
|
||||||
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos,
|
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
|
||||||
"schema: offers carry shipping, returns and sku");
|
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||||
|
"schema: variants carry shipping, returns, sku and group id");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Check(!Content::Projects().empty(), "content: projects present");
|
Check(!Content::Projects().empty(), "content: projects present");
|
||||||
|
|
@ -453,6 +462,86 @@ void RunMoneySelfTest() {
|
||||||
Check(!Content::Demos().empty(), "content: demos present");
|
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 ─────────────────────────────────
|
// ── the Sendcloud response parser ─────────────────────────────────
|
||||||
{
|
{
|
||||||
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||||
|
|
|
||||||
|
|
@ -111,9 +111,15 @@ export struct PageMeta {
|
||||||
// schema.org JSON-LD for this page, already serialized. Emitted verbatim
|
// schema.org JSON-LD for this page, already serialized. Emitted verbatim
|
||||||
// inside <script type="application/ld+json"> — inert data, not code, so
|
// inside <script type="application/ld+json"> — inert data, not code, so
|
||||||
// it does not count against the shop pages' one-executable-script rule.
|
// it does not count against the shop pages' one-executable-script rule.
|
||||||
// Set by RenderHome (Organization: who Catcrafts is, versus the two
|
// Set by RenderHome (an @graph of Organization + WebSite: who Catcrafts is
|
||||||
// name-twins) and RenderProduct (Product: what is sold, at which prices —
|
// and what this domain is, versus the two name-twins), RenderAbout
|
||||||
// the same integers the checkout charges).
|
// (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;
|
std::string jsonLd;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,11 @@ std::string JsonStr(std::string_view s) {
|
||||||
case '\n': out += "\\n"; break;
|
case '\n': out += "\\n"; break;
|
||||||
case '\r': out += "\\r"; break;
|
case '\r': out += "\\r"; break;
|
||||||
case '\t': out += "\\t"; 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:
|
default:
|
||||||
if (static_cast<unsigned char>(c) < 0x20) {
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
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 "
|
"the benefit of everyone. The goal: a real alternative to big tech "
|
||||||
"that anyone can use, not just Linux nerds.";
|
"that anyone can use, not just Linux nerds.";
|
||||||
page.meta.canonical = "/";
|
page.meta.canonical = "/";
|
||||||
// The identity record: name, registered VAT identity and the Forgejo
|
// The identity record: name, registered identity and the Forgejo profile
|
||||||
// profile separate this Catcrafts from the name-twins by type and by
|
// separate this Catcrafts from the name-twins by type and by registration,
|
||||||
// registration, not just by prose. All literal — nothing user-supplied.
|
// 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 =
|
page.meta.jsonLd =
|
||||||
R"({"@context":"https://schema.org","@type":"Organization",)"
|
R"({"@context":"https://schema.org","@graph":[)"
|
||||||
R"("name":"Catcrafts","url":"https://catcrafts.net/",)"
|
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"("logo":"https://catcrafts.net/favicon.svg",)"
|
||||||
R"("email":"info@catcrafts.net","vatID":"NL003329281B38",)"
|
R"("email":"info@catcrafts.net","vatID":"NL003329281B38",)"
|
||||||
R"("founder":{"@type":"Person","name":"Jorijn van der Graaf",)"
|
R"("identifier":[)"
|
||||||
R"("url":"https://catcrafts.net/about"},)"
|
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"("description":"Catcrafts makes accessible, open-source software and hardware: )"
|
||||||
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and Fairphone 6 )"
|
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and Fairphone 6 )"
|
||||||
R"(handsets sold with postmarketOS preinstalled.",)"
|
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);
|
page.main = std::move(main);
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
@ -481,6 +522,27 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
|
||||||
"stack Catcrafts maintains.";
|
"stack Catcrafts maintains.";
|
||||||
page.meta.canonical = "/shop";
|
page.meta.canonical = "/shop";
|
||||||
page.meta.geoPriceHint = true;
|
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(
|
page.main = Format(
|
||||||
R"(<header class="page-header">)"
|
R"(<header class="page-header">)"
|
||||||
R"(<h1 class="page-header__title">Shop</h1>)"
|
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.ogImage = product.image;
|
||||||
page.meta.geoPriceHint = true;
|
page.meta.geoPriceHint = true;
|
||||||
|
|
||||||
// The commercial record: one Offer per variant, prices from the same
|
// The commercial record: a ProductGroup with one variant Product per
|
||||||
// integers the checkout charges — this markup can never advertise a
|
// colour, each carrying its ONE offer, prices from the same integers the
|
||||||
// number the shop doesn't honour. Availability tracks the status field,
|
// checkout charges — this markup can never advertise a number the shop
|
||||||
// so launch day flips PreOrder to InStock with no edit here.
|
// 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
|
// Merchant-grade: each offer also carries shippingDetails and a return
|
||||||
// policy, which is what Google Merchant Center's website-crawl feed needs
|
// 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
|
// to list the product without a CSV in sight — productGroupID is what it
|
||||||
// zone rates on purpose: the checkout charges live carrier rates, which
|
// maps to item_group_id. Shipping uses the STATIC zone rates on purpose:
|
||||||
// run at or below the zone fallbacks — a listing may overstate shipping,
|
// the checkout charges live carrier rates, which run at or below the
|
||||||
// never understate it.
|
// zone fallbacks — a listing may overstate shipping, never understate it.
|
||||||
{
|
{
|
||||||
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
|
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
|
||||||
std::string_view availability =
|
std::string_view availability =
|
||||||
|
|
@ -765,39 +832,68 @@ export RenderedPage RenderProduct(const Product& product,
|
||||||
|
|
||||||
const std::string offerTail = std::format(
|
const std::string offerTail = std::format(
|
||||||
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
|
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":{}}})",
|
R"("shippingDetails":{},"hasMerchantReturnPolicy":{}}})",
|
||||||
availability, JsonStr(productUrl), shippingDetails, returnPolicy);
|
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()
|
const std::string brand = product.brand.empty()
|
||||||
? std::string{}
|
? std::string{}
|
||||||
: std::format(R"("brand":{{"@type":"Brand","name":{}}},)",
|
: std::format(R"("brand":{{"@type":"Brand","name":{}}},)",
|
||||||
JsonStr(product.brand));
|
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(
|
page.meta.jsonLd = std::format(
|
||||||
R"({{"@context":"https://schema.org","@type":"Product",)"
|
R"({{"@context":"https://schema.org","@type":"Product",)"
|
||||||
R"("name":{},{}"description":{},"image":{},"url":{},"offers":[{}]}})",
|
R"("name":{},{}"description":{},{}"url":{},"sku":{}{}}})",
|
||||||
JsonStr(product.name), brand, JsonStr(product.tagline),
|
JsonStr(product.name), brand, JsonStr(product.tagline), image,
|
||||||
JsonStr(product.image.empty()
|
JsonStr(productUrl), JsonStr(sku), offer);
|
||||||
? std::string{}
|
}
|
||||||
: "https://catcrafts.net" + product.image),
|
|
||||||
JsonStr(productUrl), offers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SafeHtml media = product.image.empty() ? SafeHtml{} : Format(
|
SafeHtml media = product.image.empty() ? SafeHtml{} : Format(
|
||||||
|
|
@ -1048,13 +1144,40 @@ export RenderedPage RenderAbout(const LegalPage& about) {
|
||||||
page.meta.title = "About — Catcrafts";
|
page.meta.title = "About — Catcrafts";
|
||||||
page.meta.description = about.lede;
|
page.meta.description = about.lede;
|
||||||
page.meta.canonical = "/about";
|
page.meta.canonical = "/about";
|
||||||
page.meta.jsonLd =
|
// dateModified is real metadata, not decoration: profile-page consumers
|
||||||
R"({"@context":"https://schema.org","@type":"ProfilePage","mainEntity":{)"
|
// read it to judge freshness, and the content already tracks the date.
|
||||||
R"("@type":"Person","name":"Jorijn van der Graaf","alternateName":"TheMightyCat",)"
|
page.meta.jsonLd = std::format(
|
||||||
R"("url":"https://catcrafts.net/about","nationality":"NL",)"
|
R"({{"@context":"https://schema.org","@type":"ProfilePage","dateModified":{},)"
|
||||||
R"("worksFor":{"@type":"Organization","name":"Catcrafts","url":"https://catcrafts.net/"},)"
|
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"("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(
|
page.main = Format(
|
||||||
R"(<header class="page-header">)"
|
R"(<header class="page-header">)"
|
||||||
R"(<h1 class="page-header__title">{}</h1>)"
|
R"(<h1 class="page-header__title">{}</h1>)"
|
||||||
|
|
|
||||||
21
tools/e2e.sh
21
tools/e2e.sh
|
|
@ -201,15 +201,24 @@ extract_ld() {
|
||||||
| grep -o '<script type="application/ld+json">[^<]*' \
|
| grep -o '<script type="application/ld+json">[^<]*' \
|
||||||
| sed 's/^<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"
|
ok "home Organization schema parses and carries the VAT identity"
|
||||||
else
|
else
|
||||||
bad "Organization schema" "missing, unparseable, or wrong identity"
|
bad "Organization schema" "missing, unparseable, or wrong identity"
|
||||||
fi
|
fi
|
||||||
if extract_ld /shop/fp6-pmos | jq -e '."@type" == "Product" and (.offers | length) == 3' >/dev/null 2>&1; then
|
# Variants are a ProductGroup: one variant Product per colour, each with its
|
||||||
ok "product schema parses with one offer per colour"
|
# 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
|
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
|
fi
|
||||||
body_has /shop/fp6-pmos '"price":"563.30"' "schema price is the checkout integer"
|
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
|
# 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 '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 '"sku":"fp6-pmos-green"' "offers carry per-variant skus"
|
||||||
body_has /shop/fp6-pmos '"brand":{"@type":"Brand","name":"Fairphone"}' "product carries the hardware brand"
|
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"
|
ok "shipping details cover all three zones"
|
||||||
else
|
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
|
fi
|
||||||
if [ "$SHOP_OPEN" = 1 ]; then
|
if [ "$SHOP_OPEN" = 1 ]; then
|
||||||
body_has /shop/fp6-pmos 'schema.org/InStock' "open shop maps to InStock availability"
|
body_has /shop/fp6-pmos 'schema.org/InStock' "open shop maps to InStock availability"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue