diff --git a/content/posts-sources.json b/content/posts-sources.json index 133adf6..a5915d1 100644 --- a/content/posts-sources.json +++ b/content/posts-sources.json @@ -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", diff --git a/server/implementations/main.cpp b/server/implementations/main.cpp index 4dc24d6..2634889 100644 --- a/server/implementations/main.cpp +++ b/server/implementations/main.cpp @@ -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{}); + 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":[ diff --git a/shared/interfaces/Catcrafts.Shared-Model.cppm b/shared/interfaces/Catcrafts.Shared-Model.cppm index 0adb8a7..a395d39 100644 --- a/shared/interfaces/Catcrafts.Shared-Model.cppm +++ b/shared/interfaces/Catcrafts.Shared-Model.cppm @@ -111,9 +111,15 @@ export struct PageMeta { // schema.org JSON-LD for this page, already serialized. Emitted verbatim // inside " can never appear inside the ld+json block: + // the HTML parser ends a