All checks were successful
Deploy / build-deploy (push) Successful in 4m19s
219 lines
11 KiB
C++
219 lines
11 KiB
C++
/*
|
|
catcrafts.net
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
*/
|
|
|
|
// The compiled-in catalogue and the schema.org identity graph. Content is
|
|
// code now; these assertions are the contract the shop pages rely on,
|
|
// checked against the actual shipped data.
|
|
|
|
import std;
|
|
import Catcrafts.Shared;
|
|
|
|
using namespace Catcrafts;
|
|
|
|
namespace {
|
|
|
|
int failures = 0;
|
|
|
|
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
|
if (ok) return;
|
|
++failures;
|
|
std::println(std::cerr, "FAIL: {}{}{}", what,
|
|
got.empty() ? "" : " got: ", got);
|
|
}
|
|
|
|
// ── the compiled-in catalogue ─────────────────────────────────────────
|
|
void CatalogueContract() {
|
|
using namespace Catcrafts::Money;
|
|
|
|
const auto& products = Content::Products();
|
|
Check(products.size() == 1, "content: one product");
|
|
if (products.size() == 1) {
|
|
const Product& pr = products[0];
|
|
Check(pr.slug == "fp6-pmos", "content: product slug");
|
|
// Coming-soon is the pre-launch state; launch flips it to
|
|
// "available" and this check keeps passing either way.
|
|
Check(pr.Buyable() || pr.ComingSoon(),
|
|
"content: product is buyable or deliberately coming soon");
|
|
Check(pr.variants.size() == 3, "content: three colours");
|
|
// Cost-plus pricing, derived in code: supplier + €50, exactly.
|
|
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330,
|
|
"content: green = 513.30 supplier + 50 markup");
|
|
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930,
|
|
"content: black = 519.30 supplier + 50 markup");
|
|
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488,
|
|
"content: white = 604.88 supplier + 50 markup");
|
|
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
|
|
Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant");
|
|
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
|
|
"content: cheapest is green");
|
|
Check(pr.safetyNote.find("112") != std::string::npos
|
|
&& pr.safetyNote.find("not yet verified") != std::string::npos,
|
|
"content: emergency-calling safety warning present and honest");
|
|
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 variant Product per colour, each
|
|
// with its ONE offer — built from the same integers the
|
|
// checkout charges.
|
|
{
|
|
// The listing's shipping block is now carrier data, so the
|
|
// render needs a table. US is priced here on purpose: the
|
|
// carrier will happily quote it and the shop still must not
|
|
// advertise it.
|
|
const std::vector<ShipRates> feedTable{
|
|
{ "NL", { { 2000, 895 } } },
|
|
{ "DE", { { 2000, 995 } } },
|
|
{ "GB", { { 2000, 2450 } } },
|
|
{ "US", { { 2000, 1794 } } },
|
|
};
|
|
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
|
|
auto ld = Json::Parse(pp.meta.jsonLd);
|
|
bool variantsOk = false;
|
|
if (ld && ld->IsObject()) {
|
|
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") == "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
|
|
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
|
"schema: variants carry shipping, returns, sku and group id");
|
|
// The published rates ARE the carrier's, at one unit's weight.
|
|
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
|
|
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
|
|
"schema: shipping rates come from the carrier table");
|
|
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
|
|
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
|
|
"schema: a refused destination is never advertised, priced or not");
|
|
|
|
// No table: no shipping claim. The listing loses the merchant
|
|
// block rather than inventing a rate — the whole point of
|
|
// dropping the zone fallback.
|
|
auto bare = Views::RenderProduct(pr, Rates{});
|
|
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
|
|
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
|
|
"schema: with no carrier table the offer publishes no shipping");
|
|
Check(Json::Parse(bare.meta.jsonLd).has_value()
|
|
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
|
"schema: and the rest of the record still parses");
|
|
}
|
|
}
|
|
Check(!Content::Projects().empty(), "content: projects present");
|
|
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");
|
|
}
|
|
|
|
// ── 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.
|
|
void IdentityGraph() {
|
|
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");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
CatalogueContract();
|
|
IdentityGraph();
|
|
|
|
if (failures != 0) {
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|