parent
f2d20bb409
commit
fca089c20d
5 changed files with 298 additions and 70 deletions
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue