This commit is contained in:
parent
728e48fc4f
commit
cc02023625
10 changed files with 341 additions and 39 deletions
|
|
@ -20,6 +20,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
|
||||
export module Catcrafts.Shared:Views;
|
||||
import std;
|
||||
import :Content;
|
||||
import :Html;
|
||||
import :Form;
|
||||
import :Model;
|
||||
|
|
@ -127,6 +128,40 @@ export SafeHtml RenderFooter() {
|
|||
Url("href", "/legal/imprint"));
|
||||
}
|
||||
|
||||
// ── schema.org JSON-LD ────────────────────────────────────────────────
|
||||
//
|
||||
// Machine-readable facts for crawlers and AI answer engines. This exists
|
||||
// because two name-twins (a Minecraft server on the singular domain, a cat
|
||||
// conservation program) kept absorbing the brand in search results and AI
|
||||
// overviews — one overview flatly asserted that nobody named Catcrafts sells
|
||||
// Fairphone hardware. Prose can be paraphrased away; typed identity and
|
||||
// offer data are what those systems quote.
|
||||
|
||||
// JSON string escaping. Its own function rather than the HTML escaper
|
||||
// because the rules are different (backslash and quote, not ampersand).
|
||||
std::string JsonStr(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 2);
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
out += '"';
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── home ──────────────────────────────────────────────────────────────
|
||||
|
||||
export RenderedPage RenderHome(std::span<const Project> projects,
|
||||
|
|
@ -166,6 +201,10 @@ export RenderedPage RenderHome(std::span<const Project> projects,
|
|||
R"(<a class="btn btn--primary"{}>Browse projects</a>)"
|
||||
R"(<a class="btn"{}>Browse shop</a>)"
|
||||
R"(</p>)"
|
||||
// The person-company weld, human-readable edition. The about page
|
||||
// and the Organization founder record are the machine-readable one.
|
||||
R"(<p class="hero__byline">Catcrafts is the one-person company of )"
|
||||
R"(<a{}>Jorijn van der Graaf</a>.</p>)"
|
||||
R"(</section>)"
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Featured work</h2>)"
|
||||
|
|
@ -174,6 +213,7 @@ export RenderedPage RenderHome(std::span<const Project> projects,
|
|||
R"({})",
|
||||
Url("href", "/projects"),
|
||||
Url("href", "/shop"),
|
||||
Url("href", "/about"),
|
||||
cards.empty() ? Raw(R"(<p class="empty">No featured projects yet.</p>)") : Join(cards),
|
||||
recent.empty() ? SafeHtml{} : Format(
|
||||
R"(<section class="section">)"
|
||||
|
|
@ -190,6 +230,20 @@ export RenderedPage RenderHome(std::span<const Project> projects,
|
|||
"alternative to big tech. A C++23 engine, build system and networking "
|
||||
"stack from scratch, and Linux with IMS/VoLTE on the Fairphone 6.";
|
||||
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.
|
||||
page.meta.jsonLd =
|
||||
R"({"@context":"https://schema.org","@type":"Organization",)"
|
||||
R"("name":"Catcrafts","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"("description":"Catcrafts makes accessible, open-source software and hardware: )"
|
||||
R"(the Crafter C++23 suite, the imsd IMS/VoLTE implementation, and Fairphone 6 )"
|
||||
R"(handsets sold with postmarketOS preinstalled.",)"
|
||||
R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]})";
|
||||
page.main = std::move(main);
|
||||
return page;
|
||||
}
|
||||
|
|
@ -635,8 +689,48 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
page.meta.description = product.tagline;
|
||||
page.meta.canonical = "/shop/" + product.slug;
|
||||
page.meta.ogType = "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.
|
||||
{
|
||||
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
|
||||
std::string_view availability =
|
||||
product.Buyable() ? "https://schema.org/InStock"
|
||||
: product.ComingSoon() ? "https://schema.org/PreOrder"
|
||||
: "https://schema.org/OutOfStock";
|
||||
std::string offers;
|
||||
for (const Variant& v : product.variants) {
|
||||
if (!offers.empty()) offers += ',';
|
||||
offers += std::format(
|
||||
R"({{"@type":"Offer","name":{},"price":{},"priceCurrency":"EUR",)"
|
||||
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
|
||||
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})",
|
||||
JsonStr(v.label), JsonStr(Money::FormatMinor(v.priceInclMinor)),
|
||||
availability, JsonStr(productUrl));
|
||||
}
|
||||
// A variantless product still gets its one offer from the base price.
|
||||
if (offers.empty() && product.priceInclMinor > 0) {
|
||||
offers += std::format(
|
||||
R"({{"@type":"Offer","price":{},"priceCurrency":"EUR",)"
|
||||
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
|
||||
R"("url":{},"seller":{{"@type":"Organization","name":"Catcrafts"}}}})",
|
||||
JsonStr(Money::FormatMinor(product.priceInclMinor)),
|
||||
availability, JsonStr(productUrl));
|
||||
}
|
||||
page.meta.jsonLd = std::format(
|
||||
R"({{"@context":"https://schema.org","@type":"Product",)"
|
||||
R"("name":{},"description":{},"image":{},"url":{},"offers":[{}]}})",
|
||||
JsonStr(product.name), JsonStr(product.tagline),
|
||||
JsonStr(product.image.empty()
|
||||
? std::string{}
|
||||
: "https://catcrafts.net" + product.image),
|
||||
JsonStr(productUrl), offers);
|
||||
}
|
||||
|
||||
SafeHtml media = product.image.empty() ? SafeHtml{} : Format(
|
||||
R"(<img class="product__photo" decoding="async" alt="{}"{}>)",
|
||||
Escape(product.name), Url("src", product.image));
|
||||
|
|
@ -862,6 +956,47 @@ export RenderedPage RenderLegal(const LegalPage& lp) {
|
|||
return page;
|
||||
}
|
||||
|
||||
// ── about ─────────────────────────────────────────────────────────────
|
||||
|
||||
// The person behind the company, in the same headed-sections shape (and CSS)
|
||||
// as the legal pages. Carries the ProfilePage/Person JSON-LD that formally
|
||||
// joins "Jorijn van der Graaf" to this domain — the entity link search and
|
||||
// answer engines were previously left to guess at, sometimes wrongly.
|
||||
export RenderedPage RenderAbout(const LegalPage& about) {
|
||||
std::vector<SafeHtml> sections;
|
||||
for (const LegalSection& sec : about.sections) {
|
||||
std::vector<SafeHtml> paras;
|
||||
for (const std::string& para : sec.body) {
|
||||
paras.push_back(Format(R"(<p>{}</p>)", Escape(para)));
|
||||
}
|
||||
sections.push_back(Format(
|
||||
R"(<section class="legal__section">)"
|
||||
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
|
||||
Escape(sec.heading), Join(paras)));
|
||||
}
|
||||
|
||||
RenderedPage page;
|
||||
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/"},)"
|
||||
R"("sameAs":["https://invent.kde.org/themightycat",)"
|
||||
R"("https://forgejo.catcrafts.net/Catcrafts/"]}})";
|
||||
page.main = Format(
|
||||
R"(<header class="page-header">)"
|
||||
R"(<h1 class="page-header__title">{}</h1>)"
|
||||
R"(<p class="page-header__lede">{}</p>)"
|
||||
R"(</header>)"
|
||||
R"(<div class="legal">{}</div>)",
|
||||
Escape(about.title), Escape(about.lede),
|
||||
Join(sections));
|
||||
return page;
|
||||
}
|
||||
|
||||
// ── demos ─────────────────────────────────────────────────────────────
|
||||
|
||||
export RenderedPage RenderDemos(std::span<const Demo> demos) {
|
||||
|
|
@ -1002,6 +1137,7 @@ export RenderedPage RenderRoute(const Route& route, const SiteContent& content)
|
|||
RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) {
|
||||
switch (route.kind) {
|
||||
case RouteKind::Home: return RenderHome(content.projects, content.posts);
|
||||
case RouteKind::About: return RenderAbout(Content::AboutPage());
|
||||
case RouteKind::Projects: return RenderProjects(content.projects);
|
||||
case RouteKind::Posts: return RenderPosts(content.posts);
|
||||
case RouteKind::Demos: return RenderDemos(content.demos);
|
||||
|
|
@ -1212,6 +1348,37 @@ export std::string RenderDocument(const RenderedPage& page,
|
|||
: Format(R"(<link rel="canonical"{}>)",
|
||||
Url("href", "https://catcrafts.net" + page.meta.canonical)).Str();
|
||||
|
||||
// Open Graph + JSON-LD. og: tags are what the fediverse (and every chat
|
||||
// app) renders as the link-preview card — and the fediverse is where this
|
||||
// site's traffic actually comes from, so the cards matter more than
|
||||
// usual. The JSON-LD block is inert data (type application/ld+json never
|
||||
// executes), so shop pages keep their one-executable-script guarantee.
|
||||
std::string headExtras;
|
||||
headExtras += std::format("<meta property=\"og:title\"{}>\n",
|
||||
Html::Attr("content", page.meta.title).Str());
|
||||
if (!page.meta.description.empty()) {
|
||||
headExtras += std::format("<meta property=\"og:description\"{}>\n",
|
||||
Html::Attr("content", page.meta.description).Str());
|
||||
}
|
||||
headExtras += std::format("<meta property=\"og:type\"{}>\n",
|
||||
Html::Attr("content", page.meta.ogType).Str());
|
||||
headExtras += "<meta property=\"og:site_name\" content=\"Catcrafts\">\n";
|
||||
if (!page.meta.canonical.empty()) {
|
||||
headExtras += std::format("<meta property=\"og:url\"{}>\n",
|
||||
Html::Attr("content", "https://catcrafts.net"
|
||||
+ page.meta.canonical).Str());
|
||||
}
|
||||
if (!page.meta.ogImage.empty()) {
|
||||
headExtras += std::format("<meta property=\"og:image\"{}>\n",
|
||||
Html::Attr("content", "https://catcrafts.net"
|
||||
+ page.meta.ogImage).Str());
|
||||
headExtras += "<meta name=\"twitter:card\" content=\"summary_large_image\">\n";
|
||||
}
|
||||
if (!page.meta.jsonLd.empty()) {
|
||||
headExtras += "<script type=\"application/ld+json\">"
|
||||
+ page.meta.jsonLd + "</script>\n";
|
||||
}
|
||||
|
||||
return std::format(
|
||||
"<!doctype html>\n"
|
||||
"<html lang=\"en\">\n<head>\n"
|
||||
|
|
@ -1258,7 +1425,7 @@ export std::string RenderDocument(const RenderedPage& page,
|
|||
page.meta.refreshSeconds)
|
||||
: std::string{},
|
||||
page.meta.geoPriceHint ? kGeoPriceHintScript : std::string_view{},
|
||||
canonical.empty() ? std::string{} : canonical + "\n",
|
||||
(canonical.empty() ? std::string{} : canonical + "\n") + headExtras,
|
||||
cssHref,
|
||||
bootScripts,
|
||||
nav.Str(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue